// Onboarding flow: Sign Up (ToS + 18+) then multi-step profile wizard. // Driven by a state machine. The whole flow is rendered into #onboarding. // === CACHE-BUST FIXES (applied even if index.html is cached) === (function(){ // 1. Fix voice player width (overrides cached CSS) var s = document.createElement('style'); s.textContent = '.ob-voice-player{width:100%!important;max-width:none!important}'; document.head.appendChild(s); // 2. Fix loadProfile voice URL (use relative, not absolute) var origLoadProfile = window.loadProfile; window.loadProfile = async function() { if (origLoadProfile) await origLoadProfile(); var me = await api('/api/me'); if (!me || !me.ok || !me.profile) return; var voiceAbs = (me.profile.voice_url || me.profile.voice_file_id) || ''; var voiceUrl = voiceAbs.replace(/^https?:\/\/[^/]+/, '') || voiceAbs; var voicePlay = document.getElementById('voicePlay'); var voicePlayer = document.getElementById('voicePlayer'); if (voiceUrl && voicePlay) { voicePlay.src = voiceUrl; if (voicePlayer) voicePlayer.classList.remove('hidden'); } }; })(); // === END CACHE-BUST FIXES === const OB_TOTAL = 14; // language + signup + 12 wizard steps (incl. height + location) let obData = { terms_accepted: false, name: "", birthdate: "", height: 0, gender: "", looking_for: "", city: "", province: "", bio: "", photos: [], photo_id: "", voice_file_id: "", intent: [], lifestyle: {}, interests: [], lat: null, lon: null, }; let obStep = 0; // 0 = signup, 1..OB_TOTAL = wizard steps let obDir = "fwd"; // animation direction: fwd | back let obBusy = false; function obShow() { const el = document.getElementById("onboarding"); el.classList.remove("hidden", "hiding"); document.body.classList.add("ob-open"); } function obHide() { const el = document.getElementById("onboarding"); el.classList.add("hiding"); document.body.classList.remove("ob-open"); setTimeout(() => el.classList.add("hidden"), 250); } // progress bar only shows during the wizard (steps 2..OB_TOTAL) function obProgress() { if (obStep < 2) return ""; const segs = []; const wizTotal = OB_TOTAL - 2; // steps 2..OB_TOTAL const wizCur = obStep - 2; // 0-based wizard index for (let i = 0; i < wizTotal; i++) { segs.push(`
`); } return `
${segs.join("")}
`; } function obRender() { const host = document.getElementById("onboarding"); let html = ""; if (obStep === 0) { // Language selection (very first screen) — hardcoded FA/EN so it never // depends on i18n/CUR_LANG (which can be wrong on first load). html = `

Choose your language

زبان را انتخاب کنید !

IR فارسی · FA
GB English · EN
`; } else if (obStep === 1) { html = `

${t('welcome')}

${t('welcomeDesc')}

${t('terms')}
`; } else if (obStep === 2) { html = `
${obProgress(obStep)}

${t('wizNameGender')}

`; } else if (obStep === 3) { // Birth date picker (wheel style) const isFa = CUR_LANG === 'fa'; const months = isFa ? ["فروردین","اردیبهشت","خرداد","تیر","مرداد","شهریور","مهر","آبان","آذر","دی","بهمن","اسفند"] : ["January","February","March","April","May","June","July","August","September","October","November","December"]; const cur = obData.birthdate ? obData.birthdate.split("-") : (isFa ? [1375,1,1] : [1996,1,1]); const cy = +cur[0], cm = +cur[1], cd = +cur[2]; const years = isFa ? range(1300, 1403) : range(1920, 2024); const days = range(1, 31); html = `
${obProgress(obStep)}

${t('wizBirth')}

${t('wizBirthDesc')}

${days.map(d=>`
${d}
`).join("")}
${months.map((m,i)=>`
${m}
`).join("")}
${years.map(y=>`
${y}
`).join("")}
`; } else if (obStep === 4) { // Height picker (scroll wheel, cm) const isFa = CUR_LANG === 'fa'; const curH = obData.height || (isFa ? 170 : 170); const heights = []; for (let h = 140; h <= 220; h++) heights.push(h); html = `
${obProgress(obStep)}

${t('wizHeight')}

${t('wizHeightDesc')}

${heights.map(h => `
${h} ${t('heightCm')}
`).join("")}
`; } else if (obStep === 5) { // "Who would you like to meet?" — card-style multi-select (no dropdowns) const lf = obData.looking_for; // "male" | "female" | "male,female" | "everyone" | "" const isEveryone = (lf === "everyone"); const picked = isEveryone ? ["male","female","other"] : (lf ? lf.split(",") : []); const opt = (val, key, label) => ` `; html = `
${obProgress(obStep)}

${t('wizLooking')}

${t('wizLookingDesc')}

${t('openToEveryone')}
${opt('male', 'male', t('male'))} ${opt('female', 'female', t('female'))} ${opt('other', 'other', t('other'))}
i ${t('shownToNote')}
`; } else if (obStep === 6) { html = `
${obProgress(obStep)}

${t('wizBio')}

${t('wizBioDesc')}

`; } else if (obStep === 7) { html = `
${obProgress(obStep)}

${t('wizPhotos')}

${t('wizPhotosDesc')}

${obData.photos.map((u,i)=>`
`).join("")} ${(obData.photos.length < 6) ? `
+
` : ''}
${obData.photos.length}/6
`; } else if (obStep === 8) { // "What are you hoping to find?" — relationship intent (pick 1-2) const intents = ["long_term","life_partner","casual","intimacy","marriage","non_mono"]; const cards = intents.map(k => ` `).join(""); html = `
${obProgress(obStep)}

${t('wizIntent')}

${t('wizIntentDesc')}

${cards}
i ${t('intentNote')}
`; } else if (obStep === 9) { // "Lifestyle & habits" — multi-section single-pick (scrollable) const sections = [ { key: "drinking", icon: "🍷", opts: ["yes","sometimes","rarely","no","sober"] }, { key: "smoking", icon: "🚬", opts: ["sometimes","no","yes","quitting"] }, { key: "workout", icon: "🏋️", opts: ["daily","weekly","rarely","no"] }, { key: "pets", icon: "🐾", opts: ["dog","cat","bird","fish","reptile","hamster","horse","free","want","allergic"] }, ]; const secHtml = sections.map(s => `
${s.icon} ${t('life_'+s.key)}
${s.opts.map(o => ``).join("")}
`).join(""); html = `
${obProgress(obStep)}

${t('wizLife')}

${t('wizLifeDesc')}

${secHtml}
`; } else if (obStep === 10) { // "Interests" — multi-select up to 6, grouped by category (Hinge-style) const cats = [ { key: "popular", items: [["coffee","☕","Coffee"],["video_games","🎮","Video Games"],["pizza","🍕","Pizza"],["fitness","💪","Fitness & Exercise"],["cinema","🎥","Classic Cinema"],["cooking","🍳","Cooking"],["football","⚽","Football"],["photography","📷","Photography"],["board_games","🎲","Board Games"],["anime","⛩️","Anime"]] }, { key: "arts", items: [["architecture","🏗️","Architecture"],["writing","✍️","Creative Writing"],["dance","💃","Dance"],["museums","🏛️","Museums & Galleries"],["painting","🎨","Painting"],["poetry","📜","Poetry"],["sculpture","🗿","Sculpture"],["street_art","🖼️","Street Art"],["tattoos","🖊️","Tattoos"],["theater","🎭","Theater"]] }, { key: "film", items: [["anime_f","⛩️","Anime"],["classic_cinema","🎥","Classic Cinema"],["comedy","😂","Comedy Shows"],["docs","🎬","Documentaries"],["horror","👻","Horror Films"],["indie","📽️","Indie Films"],["kdrama","📺","K-Dramas"],["reality","📺","Reality TV"],["scifi","🚀","Sci-Fi & Fantasy"],["truecrime","🔍","True Crime"]] }, { key: "food", items: [["bbq","🔥","BBQ"],["baking","🍞","Baking"],["brunch","🥞","Brunch"],["burgers","🍔","Burgers"],["cocktails","🍸","Cocktails"],["coffee_f","☕","Coffee"],["cooking_f","🍳","Cooking"],["dining","🍽️","Fine Dining"],["matcha","🍵","Matcha"],["pizza_f","🍕","Pizza"],["ramen","🍜","Ramen"],["streetfood","🌮","Street Food"],["sushi","🍣","Sushi"],["tacos","🌮","Tacos"],["vegan","🥗","Veganism"]] }, { key: "gaming", items: [["boardgames","🎲","Board Games"],["cards","🃏","Card Games"],["esports","🏆","Esports"],["mobile","📱","Mobile Gaming"],["puzzle","🧩","Puzzle Games"],["retro","👾","Retro Gaming"],["ttrpg","🐉","Tabletop RPGs"],["videogames","🎮","Video Games"]] }, { key: "music", items: [["classical","🎻","Classical Music"],["djing","🎧","DJing"],["guitar","🎸","Guitar"]] }, { key: "sports", items: [["running","🏃","Running"],["cycling","🚴","Cycling"],["swimming","🏊","Swimming"],["yoga","🧘","Yoga"],["climbing","🧗","Climbing"],["tennis","🎾","Tennis"],["basketball","🏀","Basketball"],["skiing","🎿","Skiing"],["surfing","🏄","Surfing"],["hiking","🥾","Hiking"]] }, { key: "travel", items: [["beach","🏖️","Beach"],["mountains","⛰️","Mountains"],["citytrip","🏙️","City Trips"],["roadtrip","🚗","Road Trips"],["camping","⛺","Camping"],["backpacking","🎒","Backpacking"],["flights","✈️","Traveling"],["culture","🗿","Culture Trips"]] }, { key: "nature", items: [["plants","🪴","Plants"],["gardening","🌱","Gardening"],["animals","🐾","Animals"],["ocean","🌊","Ocean"],["stars","🌌","Stargazing"],["forests","🌲","Forests"],["sunsets","🌅","Sunsets"]] }, { key: "books", items: [["reading","📚","Reading"],["fiction","📖","Fiction"],["poetry_b","📜","Poetry"],["scifi_b","🚀","Sci-Fi"],["history","📜","History"],["manga","🇯🇵","Manga"],["philosophy","🤔","Philosophy"]] }, { key: "pets", items: [["dogs","🐕","Dogs"],["cats","🐈","Cats"],["birds","🐦","Birds"],["fish","🐠","Fish"],["rabbits","🐇","Rabbits"],["reptiles","🦎","Reptiles"]] }, { key: "wellness", items: [["meditation","🧘","Meditation"],["fitness_w","💪","Working Out"],["nutrition","🥗","Nutrition"],["spa","💆","Spas"],["sleep","😴","Sleep"],["mindful","🌿","Mindfulness"]] }, ]; const catHtml = cats.map(c => `
${t('int_cat_'+c.key)}
${c.items.map(([id,emo,label]) => ``).join("")}
`).join(""); const cnt = obData.interests.length; html = `
${obProgress(obStep)}

${t('wizInterests')}

${t('wizInterestsDesc')}

${catHtml}
${t('skip')} › ${cnt}/6 ${t('selected')}
`; } else if (obStep === 11) { const hasVoice = !!obData.voice_file_id; const uploaded = !!obData.voiceUploaded; // true after successful upload (survives re-render) html = `
${obProgress(obStep)}

${t('wizVoice')}

${t('wizVoiceDesc')}

${hasVoice ? t('voiceReady') : t('tapToRecord')}
0:00
${t('voiceUploaded')}
`; } else if (obStep === 12) { // Location capture via native OS permission (Telegram Mini App → iOS/Android prompt) const hasGPS = (obData.lat != null && obData.lon != null); html = `
${obProgress(obStep)}
📍

${t('wizLoc')}

${t('wizLocDesc')}

${hasGPS ? (obData.city ? `${t('locSet')} — ${obData.city}` : t('locSet')) : ''}
${t('locHow')} ▾
`; } else if (obStep === 13) { html = `
${obProgress(obStep)}

${t('wizDone')}

${t('wizDoneDesc')}

`; } const centerCls = (obStep <= 13) ? 'center' : ''; host.innerHTML = html.replace(/class="ob-card"/g, `class="ob-card ${centerCls} ${obDir==='back'?'back':''}"`); if (obStep === 3 || obStep === 4) { document.querySelectorAll('.wheel').forEach(w => { w.addEventListener('scroll', () => obWheelScroll(w, false)); w.addEventListener('scrollend', () => obWheelScroll(w, true)); const sel = w.querySelector('.sel'); if (sel) w.scrollTop = sel.offsetTop - (w.clientHeight/2 - sel.clientHeight/2); }); } obShow(); } function obPickLang(lang) { setLang(lang); obData.lang = lang; obStep = 1; obRender(); } function obGotoLang() { obStep = 0; obDir = "back"; obRender(); } async function obSignup() { const terms = document.getElementById("ob_settings_check").checked; const err = document.getElementById("ob_err"); if (!terms) { err.textContent = t('errTerms'); return; } try { const r = await api("/api/signup", { method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ terms_accepted: true, lang: obData.lang || CUR_LANG }) }); if (!r || !r.ok) { err.textContent = t('errSignup') || "خطا در ثبت‌نام، دوباره تلاش کن"; return; } } catch (e) { err.textContent = (t('errSignup') || "خطا در ثبت‌نام") + " (" + (e && e.message ? e.message : "network") + ")"; return; } obData.terms_accepted = true; obStep = 2; obDir = "fwd"; obRender(); } function obToggleTerms() { const cb = document.getElementById("ob_settings_check"); cb.checked = !cb.checked; } async function obSaveStep1() { const err = document.getElementById("ob_err"); const name = document.getElementById("ob_name").value.trim(); const gender = document.getElementById("ob_gender").value; if (!name) { err.textContent = t('errName'); return; } if (!gender) { err.textContent = t('errGender'); return; } obData.name = name; obData.gender = gender; try { obSetNextLoading(true); const ok = await obSaveStepToServer(2); if (!ok) throw new Error('save failed'); } catch (e) { err.textContent = CUR_LANG === 'fa' ? 'ذخیره اطلاعات ناموفق بود.' : 'Failed to save.'; obSetNextLoading(false); return; } obSetNextLoading(false); obStep = 3; obDir = "fwd"; obRender(); } function range(a, b) { const r = []; for (let i = a; i <= b; i++) r.push(i); return r; } // Age-confirm modal: shows the computed age and asks the user to confirm, // because age is permanent. Called from obSaveBirth after a valid (>=18) date. function obConfirmAge() { const age = calcAge(obData.birthdate); const modal = document.createElement("div"); modal.id = "obAgeModal"; modal.className = "ob-age-modal"; modal.innerHTML = `

${t('ageConfirmTitle', { age })}

${t('ageConfirmDesc')}

`; document.body.appendChild(modal); } async function obConfirmAgeOk() { const m = document.getElementById("obAgeModal"); if (m) m.remove(); try { obSetNextLoading(true); const ok = await obSaveStepToServer(3); if (!ok) throw new Error('save failed'); } catch (e) { const err = document.getElementById("ob_err"); if (err) err.textContent = CUR_LANG === 'fa' ? 'ذخیره تاریخ تولد ناموفق بود.' : 'Failed to save birth date.'; obSetNextLoading(false); return; } obSetNextLoading(false); obStep = 4; obDir = "fwd"; obRender(); } function obConfirmAgeCancel() { const m = document.getElementById("obAgeModal"); if (m) m.remove(); } // Birth-date wheel: read selected day/month/year from the three wheels function obSaveBirth() { const err = document.getElementById("ob_err"); const d = +document.getElementById("w_day").dataset.val || +document.querySelector("#w_day .sel")?.dataset.val || 1; const m = +document.getElementById("w_month").dataset.val || +document.querySelector("#w_month .sel")?.dataset.val || 1; const y = +document.getElementById("w_year").dataset.val || +document.querySelector("#w_year .sel")?.dataset.val || (CUR_LANG==='fa'?1375:1996); const isFa = CUR_LANG === 'fa'; obData.birthdate = `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`; const gy = isFa ? y + 621 : y; const age = new Date().getFullYear() - gy; if (age < 18) { err.textContent = t('errAgeValid'); return; } // open the age-confirm modal instead of advancing immediately obConfirmAge(); } // Wheel scroll handler — snap to nearest item function obSaveHeight() { const err = document.getElementById("ob_err"); const h = +document.getElementById("w_height").dataset.val || +document.querySelector("#w_height .sel")?.dataset.val || 0; if (h < 140 || h > 220) { err.textContent = t('errHeight'); return; } obData.height = h; obNext(); } function obWheelScroll(wheel, commit) { const items = wheel.querySelectorAll(".w-item"); const rect = wheel.getBoundingClientRect(); const mid = rect.top + rect.height / 2; let best = null, bestDist = 1e9; items.forEach(it => { const r = it.getBoundingClientRect(); const c = r.top + r.height / 2; const dist = Math.abs(c - mid); if (dist < bestDist) { bestDist = dist; best = it; } }); items.forEach(it => it.classList.remove("sel")); if (best) { best.classList.add("sel"); if (commit) wheel.dataset.val = best.dataset.val; } } function obPrev() { if (obStep > 0) { obStep--; obDir = "back"; obRender(); } } function obFieldsForStep(step) { // Map each wizard step to the profile fields it collects, so we can persist // them server-side on Next (server is source of truth, not localStorage). // Array/object fields must be JSON.stringified for the DB column (TEXT). switch (step) { case 2: return { name: obData.name, gender: obData.gender }; case 3: return { birthdate: obData.birthdate }; case 4: return { height: obData.height || 0 }; case 5: return { looking_for: obData.looking_for }; case 6: return { bio: obData.bio }; case 7: return { photo_id: obData.photo_id || "", photos: JSON.stringify(obData.photos || []) }; case 8: return { intent: JSON.stringify(obData.intent || []) }; case 9: return { lifestyle: JSON.stringify(obData.lifestyle || {}) }; case 10: return { interests: JSON.stringify(obData.interests || []) }; case 11: return { voice_file_id: obData.voice_file_id || "" }; case 12: return { lat: obData.lat, lon: obData.lon, city: obData.city || "", province: obData.province || "" }; default: return {}; } } async function obSaveStepToServer(step) { const fields = obFieldsForStep(step); if (!fields || Object.keys(fields).length === 0) return true; const r = await api("/api/save_step", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step, fields }), }); return !!(r && r.ok); } function obSetNextLoading(loading) { const btn = document.querySelector('.ob-next'); if (!btn) return; if (loading) { btn.dataset.prevText = btn.textContent; btn.disabled = true; btn.textContent = CUR_LANG === 'fa' ? 'در حال ذخیره...' : 'Saving...'; } else { btn.disabled = false; btn.textContent = btn.dataset.prevText || btn.textContent; } } async function obNext() { // step 5 (looking_for): require at least one selection before advancing if (obStep === 5) { const lf = obData.looking_for; const picked = (lf === "everyone") ? ["male","female","other"] : (lf ? lf.split(",").filter(Boolean) : []); if (picked.length === 0) { const err = document.getElementById("ob_err"); if (err) err.textContent = t('errLooking'); return; } } // step 8 (intent): require at least one selection before advancing if (obStep === 8) { if (obData.intent.length === 0) { const err = document.getElementById("ob_err"); if (err) err.textContent = t('errIntent'); return; } } // step 7 (photos): require at least one uploaded photo before advancing if (obStep === 7) { if (!obData.photo_id || obData.photos.length === 0) { const err = document.getElementById("ob_err"); if (err) err.textContent = t('errPhoto'); return; } } // step 12 (location): require a captured location before advancing if (obStep === 12) { if (obData.lat == null || obData.lon == null) { const err = document.getElementById("ob_err"); if (err) err.textContent = t('errLoc'); // nudge the user to tap Allow (don't silently pass) const status = document.getElementById("obLocStatus"); if (status && !status.textContent) status.textContent = t('locPleaseAllow'); return; } } // step 6: bio is optional, just capture it if (obStep === 6) { const bioEl = document.getElementById("ob_bio"); obData.bio = bioEl ? bioEl.value.trim() : obData.bio; } // step 11 (voice): if a recording exists but wasn't uploaded yet, upload it now // before advancing (no separate confirm button — Next saves & uploads). if (obStep === 11 && _recBlob && !obData.voiceUploaded) { await obConfirmVoice(); } const finishedStep = obStep; try { obSetNextLoading(true); const ok = await obSaveStepToServer(finishedStep); if (!ok) throw new Error('save failed'); } catch (e) { const err = document.getElementById("ob_err"); if (err) err.textContent = CUR_LANG === 'fa' ? 'ذخیره اطلاعات ناموفق بود. دوباره تلاش کن.' : 'Failed to save your progress. Please try again.'; obSetNextLoading(false); return; } obStep++; obDir = "fwd"; obSetNextLoading(false); obRender(); } // Card selection for "who would you like to meet?" — multi-select (up to 3). // Selecting a 3rd option auto-enables "everyone"; dropping back to <=2 turns it off. // Updates only the affected elements (no full re-render -> no page transition). function obPickLooking(val) { let cur = obData.looking_for; if (cur === "everyone") { // currently "everyone" (all three selected). Turning one off should only // drop that option and keep the other two -> toggle goes off, others stay. const remaining = ["male","female","other"].filter(v => v !== val); obData.looking_for = remaining.join(","); } else { let arr = cur ? cur.split(",").filter(Boolean) : []; if (arr.includes(val)) { arr = arr.filter(v => v !== val); // unselect } else { arr.push(val); // select } if (arr.length >= 3) { obData.looking_for = "everyone"; // all three -> everyone mode } else { obData.looking_for = arr.join(","); } } _syncLookingUI(); } function _syncLookingUI() { const lf = obData.looking_for; const isEveryone = (lf === "everyone"); const picked = isEveryone ? ["male","female","other"] : (lf ? lf.split(",") : []); document.querySelectorAll(".ob-pref-card[data-val]").forEach(card => { card.classList.toggle("sel", picked.includes(card.getAttribute("data-val"))); }); const tog = document.getElementById("obEveryoneToggle"); if (tog) tog.classList.toggle("on", isEveryone); } // Relationship intent: pick 1-2. Selecting a 3rd drops the oldest selected one. // Updates only the affected cards (no full re-render -> no page transition). function obPickIntent(val) { let arr = obData.intent.slice(); if (arr.includes(val)) { arr = arr.filter(v => v !== val); // unselect } else { arr.push(val); // select if (arr.length > 2) arr.shift(); // keep only the 2 most recent } obData.intent = arr; document.querySelectorAll(".ob-intent[data-val]").forEach(card => { card.classList.toggle("sel", arr.includes(card.getAttribute("data-val"))); }); } // Lifestyle & habits: single-pick per section (no full re-render -> no transition) function obPickLife(section, val) { if (obData.lifestyle[section] === val) { delete obData.lifestyle[section]; // tap again to clear } else { obData.lifestyle[section] = val; } document.querySelectorAll(".ob-life-pill").forEach(btn => { const [s, v] = (btn.getAttribute("data-val") || "").split("|"); btn.classList.toggle("sel", obData.lifestyle[s] === v); }); } // Interests: multi-select up to 6 (Hinge-style). DOM-only update -> no transition, counter refreshes. function obPickInterest(id) { let arr = obData.interests.slice(); if (arr.includes(id)) { arr = arr.filter(v => v !== id); } else { if (arr.length >= 6) return; // hard cap at 6 arr.push(id); } obData.interests = arr; const btn = document.querySelector(`.ob-int-pill[data-id="${id}"]`); if (btn) btn.classList.toggle("sel", arr.includes(id)); const cnt = document.querySelector(".ob-int-count"); if (cnt) cnt.textContent = `${arr.length}/6 ${t('selected')}`; } // Card selection for the user's own gender (step 2) function obPickGender(val) { if (obData.gender === val) return; obData.gender = val; obRender(); } // Toggle "I'm open to dating everyone" -> sets looking_for to "everyone" function obToggleEveryone() { const on = (obData.looking_for === "everyone"); if (on) { // turn off: revert to the last multi-select if we stored one, else empty obData.looking_for = obData._lastLooking && obData._lastLooking !== "everyone" ? obData._lastLooking : ""; } else { obData._lastLooking = obData.looking_for; // remember picks behind "everyone" obData.looking_for = "everyone"; } _syncLookingUI(); } function obAddPhoto() { document.getElementById("ob_file").click(); } // Crop modal: after a file is picked, show a zoomable/pan-able crop UI (3:4 ratio). // User taps Apply → canvas-crop → upload. function obStartCrop(file, onCropped) { if (!file) return; window._cropOnCropped = onCropped || null; // Close any lingering crop modal const old = document.getElementById("obCrop"); if (old) old.remove(); const url = URL.createObjectURL(file); const modal = document.createElement("div"); modal.id = "obCrop"; modal.className = "ob-crop"; modal.innerHTML = `

${t('cropTitle') || 'Crop photo'}

${t('cropWarn') || 'Zoom to fill the entire frame'}
`; document.body.appendChild(modal); initCrop(); } function obCloseCrop() { const m = document.getElementById("obCrop"); if (m) m.remove(); initCrop._done = false; _crop = { x: 0, y: 0, scale: 1 }; document.body.style.overflow = ""; document.body.style.touchAction = ""; document.body.style.position = ""; document.body.style.width = ""; document.body.style.height = ""; } // Full-featured crop: photo starts fully visible (contain), user can zoom // in/out with + / - buttons AND drag to pan. Mirrors reference apps where // the whole photo is reachable and any region can be framed at 3:4. let _crop = { x: 0, y: 0, scale: 1 }; function initCrop() { const img = document.getElementById("obCropImg"); const stage = document.getElementById("obCropStage"); if (!img || !stage) return; const setup = () => { if (initCrop._done) return; initCrop._done = true; const Sw = stage.clientWidth, Sh = stage.clientHeight; const dims = () => { const natW = img.naturalWidth || 1, natH = img.naturalHeight || 1; return { natW, natH, contain: Math.min(Sw / natW, Sh / natH), cover: Math.max(Sw / natW, Sh / natH), }; }; const boxW = () => dims().natW * _crop.scale; const boxH = () => dims().natH * _crop.scale; const clampPosition = () => { const { natW, natH } = dims(); const bw = natW * _crop.scale, bh = natH * _crop.scale; if (bw <= Sw) _crop.x = (Sw - bw) / 2; else _crop.x = Math.min(0, Math.max(Sw - bw, _crop.x)); if (bh <= Sh) _crop.y = (Sh - bh) / 2; else _crop.y = Math.min(0, Math.max(Sh - bh, _crop.y)); }; // initial: photo FILLS the frame (cover) so no black bars appear _crop.scale = dims().cover; _crop.x = (Sw - boxW()) / 2; _crop.y = (Sh - boxH()) / 2; // ── GPU-accelerated render (translate3d, no left/top/width reflow) ── let raf = null; const render = () => { raf = null; img.style.transform = `translate3d(${_crop.x}px, ${_crop.y}px, 0) scale(${_crop.scale})`; }; const apply = () => { if (raf) return; raf = requestAnimationFrame(render); }; img.style.width = dims().natW + "px"; img.style.height = dims().natH + "px"; img.style.transformOrigin = "0 0"; img.style.willChange = "transform"; img.style.backfaceVisibility = "hidden"; img.style.touchAction = "none"; stage.style.touchAction = "none"; stage.style.overscrollBehavior = "contain"; const fillsFrame = () => boxW() >= Sw - 0.5 && boxH() >= Sh - 0.5; const warn = document.getElementById("obCropWarn"); const refreshApply = () => { const btn = document.getElementById("obCropApply"); if (!btn) return; const ok = fillsFrame(); btn.disabled = !ok; btn.style.opacity = ok ? "1" : "0.45"; if (warn) warn.style.display = ok ? "none" : "block"; }; // ── Lock body scroll for the ENTIRE modal session, not per-gesture ── // This prevents the Telegram WebView from intercepting drag as page scroll. document.body.style.overflow = "hidden"; document.body.style.touchAction = "none"; document.body.style.position = "fixed"; document.body.style.width = "100%"; document.body.style.height = "100%"; // ── Unified gesture system: ALL pointers go through stage, not img ── // Single handler set, no conflicts between img and stage listeners. const pts = new Map(); // pointerId → {x, y} let dragId = null; // which pointer is doing the drag let sx = 0, sy = 0, ox = 0, oy = 0; let pinchDist0 = 0, pinchScale0 = 1; const zoom = (d, cx, cy) => { const { natW, natH, cover } = dims(); const minScale = cover, maxScale = cover * 4; const ns = Math.min(maxScale, Math.max(minScale, _crop.scale * (1 + d))); if (cx != null) { const rx = (cx - _crop.x) / _crop.scale; const ry = (cy - _crop.y) / _crop.scale; _crop.scale = ns; _crop.x = cx - rx * _crop.scale; _crop.y = cy - ry * _crop.scale; } else { _crop.x = (Sw - natW * ns) / 2; _crop.y = (Sh - natH * ns) / 2; } clampPosition(); apply(); refreshApply(); }; // ── Touch events: hard preventDefault at the STAGE level ── // This stops the WebView from interpreting ANY touch inside the crop // as a scroll / pull-to-refresh / mini-app-minimize gesture. stage.addEventListener("touchstart", (e) => { e.preventDefault(); }, { passive: false }); stage.addEventListener("touchmove", (e) => { e.preventDefault(); }, { passive: false }); stage.addEventListener("touchend", (e) => { e.preventDefault(); }, { passive: false }); stage.addEventListener("touchcancel", (e) => { e.preventDefault(); }, { passive: false }); // ── Pointer events for actual gesture logic ── stage.addEventListener("pointerdown", (e) => { e.preventDefault(); stage.setPointerCapture(e.pointerId); pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pts.size === 1) { // Start drag dragId = e.pointerId; sx = e.clientX; sy = e.clientY; ox = _crop.x; oy = _crop.y; } else if (pts.size === 2) { // Start pinch — cancel drag, switch to pinch mode dragId = null; const [a, b] = [...pts.values()]; pinchDist0 = Math.hypot(a.x - b.x, a.y - b.y); pinchScale0 = _crop.scale; } }); stage.addEventListener("pointermove", (e) => { if (!pts.has(e.pointerId)) return; e.preventDefault(); pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pts.size === 2) { // ── Pinch zoom ── const [a, b] = [...pts.values()]; const dist = Math.hypot(a.x - b.x, a.y - b.y); if (pinchDist0 > 0) { const r = stage.getBoundingClientRect(); const mid = { x: (a.x + b.x) / 2 - r.left, y: (a.y + b.y) / 2 - r.top }; const ratio = dist / pinchDist0; const ns = Math.min(dims().cover * 4, Math.max(dims().cover, pinchScale0 * ratio)); const cx = mid.x, cy = mid.y; const rx = (cx - _crop.x) / _crop.scale; const ry = (cy - _crop.y) / _crop.scale; _crop.scale = ns; _crop.x = cx - rx * ns; _crop.y = cy - ry * ns; clampPosition(); apply(); refreshApply(); } } else if (pts.size === 1 && dragId !== null) { // ── Drag (pan) ── _crop.x = ox + (e.clientX - sx); _crop.y = oy + (e.clientY - sy); apply(); refreshApply(); } }); const endPointer = (e) => { e.preventDefault(); pts.delete(e.pointerId); if (e.pointerId === dragId) dragId = null; if (pts.size < 2) pinchDist0 = 0; if (pts.size === 0) { // All fingers lifted — snap back into bounds clampPosition(); apply(); refreshApply(); } else if (pts.size === 1) { // Went from pinch to single finger — start dragging with the remaining one const [remaining] = [...pts.entries()]; dragId = remaining[0]; sx = remaining[1].x; sy = remaining[1].y; ox = _crop.x; oy = _crop.y; } }; stage.addEventListener("pointerup", endPointer); stage.addEventListener("pointercancel", endPointer); // ── Mouse wheel zoom (desktop testing) ── stage.addEventListener("wheel", (e) => { e.preventDefault(); const r = stage.getBoundingClientRect(); zoom(e.deltaY < 0 ? 0.18 : -0.18, e.clientX - r.left, e.clientY - r.top); }, { passive: false }); const applyBtn = document.getElementById("obCropApply"); if (applyBtn) { applyBtn.onclick = () => { if (!fillsFrame()) return; // guard: never export with black bars const outW = 600, outH = 800, k = outW / Sw; const canvas = document.createElement("canvas"); canvas.width = outW; canvas.height = outH; const ctx = canvas.getContext("2d"); ctx.drawImage(img, _crop.x * k, _crop.y * k, boxW() * k, boxH() * k); canvas.toBlob((blob) => { if (!blob) { obCloseCrop(); return; } const cropped = new File([blob], "photo.jpg", { type: "image/jpeg" }); obCloseCrop(); // Use custom callback if provided, otherwise wizard default if (typeof window._cropOnCropped === "function") { window._cropOnCropped(cropped); } else { doUpload(cropped); } }, "image/jpeg", 0.9); }; } apply(); refreshApply(); }; if (img.complete && img.naturalWidth) setup(); else img.onload = setup; } async function obUpload(input) { // Accept: a , {files:[File]}, or a bare File/Blob. let file = null; if (input instanceof Blob) file = input; else if (input && input.files && input.files[0]) file = input.files[0]; else if (input && Array.isArray(input.files) && input.files[0]) file = input.files[0]; if (!file) return; if (obData.photos.length >= 6) { obRender(); return; } const spinner = document.getElementById("ob_spinner"); const add = document.getElementById("ob_add"); if (spinner) { spinner.classList.remove("hidden"); try { const url = URL.createObjectURL(compressed); spinner.style.backgroundImage = `url('${url}')`; } catch (e) {} } if (add) add.style.display = "none"; // compress, but fall back to the original file if canvas fails (older WebViews) let compressed = file; try { compressed = await compressImage(file, 1080, 0.82); } catch (e) { compressed = file; } const fd = new FormData(); fd.append("file", compressed, "photo.jpg"); try { const r = await api("/api/upload_photo", { method:"POST", body: fd }); if (r.ok && r.url) { const full = r.url.startsWith("http") ? r.url : location.origin + r.url; if (obData.photos.length < 6) { obData.photos.push(full); if (!obData.photo_id) obData.photo_id = full; } // Persist immediately so reopening the wizard keeps the photo obSaveStepToServer(7); } } catch (e) {} obRender(); if (input && input.value !== undefined) input.value = ""; } // Convert a File/Blob to a base64 data URL (used to survive iOS reloads). function fileToDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } // Photos are uploaded straight to the server (server = source of truth), // so there is no pending-photo resume needed. async function resumePendingPhoto() { return; } // Upload a (possibly already-cropped) File/Blob async function doUpload(blob) { if (!blob) return; if (obData.photos.length >= 6) { obRender(); return; } const spinner = document.getElementById("ob_spinner"); const add = document.getElementById("ob_add"); if (spinner) { spinner.classList.remove("hidden"); try { const url = URL.createObjectURL(compressed); spinner.style.backgroundImage = `url('${url}')`; } catch (e) {} } if (add) add.style.display = "none"; const fd = new FormData(); fd.append("file", blob, "photo.jpg"); try { const r = await api("/api/upload_photo", { method:"POST", body: fd }); if (r.ok && r.url) { const full = r.url.startsWith("http") ? r.url : location.origin + r.url; if (obData.photos.length < 6) { obData.photos.push(full); if (!obData.photo_id) obData.photo_id = full; } // Persist immediately so reopening the wizard keeps the photo obSaveStepToServer(7); } } catch (e) {} obRender(); } function obDelPhoto(u) { const i = obData.photos.indexOf(u); if (i === -1) return; obData.photos.splice(i, 1); if (obData.photo_id === u) obData.photo_id = obData.photos[0] || ""; obRender(); } // Lightbox: show a large view of a photo (tap the thumbnail to open) function obZoom(u) { const m = document.createElement("div"); m.className = "ob-zoom"; m.onclick = () => m.remove(); m.innerHTML = `
${t('tapToClose')}
`; document.body.appendChild(m); } // Resize + recompress an image file to a uniform size/quality using canvas. // Falls back to the original file if canvas/blob is unsupported (some WebViews). function compressImage(file, maxDim, quality) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = e => { const img = new Image(); img.onload = () => { try { let { width, height } = img; if (width > maxDim || height > maxDim) { const ratio = Math.min(maxDim / width, maxDim / height); width = Math.round(width * ratio); height = Math.round(height * ratio); } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, width, height); // toBlob is preferred but some WebViews hang on it; use toDataURL then convert let dataUrl = null; try { dataUrl = canvas.toDataURL("image/jpeg", quality); } catch (err) {} if (dataUrl && dataUrl.startsWith("data:image")) { // convert dataURL -> Blob const [meta, b64] = dataUrl.split(","); const mime = (meta.match(/data:(\w+\/\w+)/) || [, "image/jpeg"])[1]; const bin = atob(b64); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); resolve(new Blob([arr], { type: mime })); return; } // fallback: toBlob canvas.toBlob(b => resolve(b || file), "image/jpeg", quality); } catch (err) { resolve(file); } }; img.onerror = () => resolve(file); img.src = e.target.result; }; reader.onerror = () => resolve(file); reader.readAsDataURL(file); // safety timeout: if nothing resolves in 8s, send original setTimeout(() => resolve(file), 8000); }); } function obSetMain(u) { obData.photo_id = u; obRender(); } // Voice recording — Telegram-style. Uses Web Audio API + WAV export so the // resulting file plays on EVERY platform (iOS Safari cannot play webm/ogg). let _rec = null, _recChunks = [], _recTimer = null, _recStart = 0, _recBlob = null; let _audioCtx = null, _micStream = null, _scriptNode = null, _recSamples = []; async function obToggleRec() { const btn = document.getElementById("obRecBtn"); const timer = document.getElementById("obRecTimer"); const status = document.getElementById("obRecStatus"); // STOP if currently recording if (_rec === "recording") { _stopRecording(); if (btn) { btn.classList.remove("recording"); btn.classList.add("done"); btn.querySelector(".ob-rec-icon").textContent = "🎙"; } if (status) status.textContent = t('voiceReady'); const actions = document.getElementById("obRecActions"); if (actions) actions.classList.remove("hidden"); return; } // START recording obData.voiceUploaded = false; // a fresh recording invalidates the "uploaded" state try { _micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const srcNode = _audioCtx.createMediaStreamSource(_micStream); const bufSize = 4096; _recSamples = []; _scriptNode = _audioCtx.createScriptProcessor(bufSize, 1, 1); _scriptNode.onaudioprocess = (e) => { const ch = e.inputBuffer.getChannelData(0); _recSamples.push(new Float32Array(ch)); }; srcNode.connect(_scriptNode); _scriptNode.connect(_audioCtx.destination); _rec = "recording"; _recStart = Date.now(); if (btn) { btn.classList.add("recording"); btn.classList.remove("done"); btn.querySelector(".ob-rec-icon").textContent = "■"; } if (status) status.textContent = t('recording'); // hide any previous player + reset waveform so the new recording gets a fresh one const player = document.getElementById("obVoicePlayer"); if (player) player.classList.add("hidden"); const wave = document.getElementById("obVpWave"); if (wave) { wave.innerHTML = ""; wave.dataset.built = ""; } const actions = document.getElementById("obRecActions"); if (actions) actions.classList.add("hidden"); if (timer) { const tick = () => { const s = Math.floor((Date.now() - _recStart) / 1000); const mm = String(Math.floor(s / 60)).padStart(2, "0"); const ss = String(s % 60).padStart(2, "0"); timer.textContent = mm + ":" + ss; }; tick(); _recTimer = setInterval(tick, 250); } // auto-stop at 30s like Telegram _recAutoStop = setTimeout(() => { if (_rec === "recording") _stopRecording(); }, 30000); } catch (e) { alert(t('micNeeded') || "دسترسی میکروفون لازمه 🎙"); } } let _recAutoStop = null; function _stopRecording() { if (_recTimer) { clearInterval(_recTimer); _recTimer = null; } if (_recAutoStop) { clearTimeout(_recAutoStop); _recAutoStop = null; } try { _scriptNode && _scriptNode.disconnect(); } catch (e) {} try { _micStream && _micStream.getTracks().forEach(t => t.stop()); } catch (e) {} try { _audioCtx && _audioCtx.close(); } catch (e) {} // flatten samples -> WAV blob const flat = []; let total = 0; for (const s of _recSamples) total += s.length; for (const s of _recSamples) for (let i = 0; i < s.length; i++) flat.push(s[i]); const sampleRate = _audioCtx ? _audioCtx.sampleRate : 44100; _recBlob = _encodeWav(flat, sampleRate); _rec = null; const url = URL.createObjectURL(_recBlob); _setupVoicePlayer(url, true, flat, sampleRate); // update record button + status to "stopped" state (covers auto-stop at 30s too) const btn = document.getElementById("obRecBtn"); const status = document.getElementById("obRecStatus"); if (btn) { btn.classList.remove("recording"); btn.classList.add("done"); btn.querySelector(".ob-rec-icon").textContent = "🎙"; } if (status) status.textContent = t('voiceReady'); const actions = document.getElementById("obRecActions"); if (actions) actions.classList.remove("hidden"); // Auto-upload the voice right after recording stops (no confirm button needed) if (_recBlob && !obData.voiceUploaded) { obConfirmVoice(); } } // Encode Float32 PCM into a 16-bit WAV (universally playable, incl. iOS). function _encodeWav(samples, sampleRate) { const buffer = new ArrayBuffer(44 + samples.length * 2); const view = new DataView(buffer); const writeStr = (off, str) => { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)); }; writeStr(0, "RIFF"); view.setUint32(4, 36 + samples.length * 2, true); writeStr(8, "WAVE"); writeStr(12, "fmt "); view.setUint32(16, 16, true); view.setUint32(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); writeStr(36, "data"); view.setUint32(40, samples.length * 2, true); let off = 44; for (let i = 0; i < samples.length; i++) { let s = Math.max(-1, Math.min(1, samples[i])); view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true); off += 2; } return new Blob([view], { type: "audio/wav" }); } // Build a real waveform from the recorded samples + Telegram-style pink player. function _setupVoicePlayer(src, local, samples, sampleRate) { const player = document.getElementById("obVoicePlayer"); const audio = document.getElementById("ob_voice"); const wave = document.getElementById("obVpWave"); const timeEl = document.getElementById("obVpTime"); const playBtn = document.getElementById("obVpPlay"); if (!player || !audio || !wave) return; audio.src = src; player.classList.remove("hidden"); // real waveform from peak amplitudes of the recorded samples if (!wave.dataset.built && samples) { const N = 48; const block = Math.floor(samples.length / N) || 1; let bars = ""; for (let i = 0; i < N; i++) { let peak = 0; const start = i * block; for (let j = 0; j < block; j++) { const v = Math.abs(samples[start + j] || 0); if (v > peak) peak = v; } const h = Math.max(8, Math.round(peak * 100)); bars += ``; } wave.innerHTML = bars; wave.dataset.bars = String(N); wave.dataset.built = "1"; } else if (!wave.dataset.built) { let bars = ""; for (let i = 0; i < 48; i++) { const h = 20 + Math.round(Math.abs(Math.sin(i * 1.7)) * 60 + Math.random() * 15); bars += ``; } wave.innerHTML = bars; wave.dataset.bars = "48"; wave.dataset.built = "1"; } playBtn.textContent = "▶"; let _dur = 0; const fmt = (s) => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0"); }; const paint = () => { const pct = _dur ? (audio.currentTime / _dur) : 0; const sp = wave.querySelectorAll("span"); const lit = Math.max(0, Math.min(sp.length, Math.round(pct * sp.length))); for (let i = 0; i < sp.length; i++) { sp[i].style.background = i < lit ? "#fff" : "rgba(255,255,255,.4)"; } }; const update = () => { timeEl.textContent = fmt(audio.currentTime); paint(); }; audio.ontimeupdate = update; audio.onloadedmetadata = () => { _dur = audio.duration || 0; timeEl.textContent = "0:00"; paint(); }; audio.onended = () => { playBtn.textContent = "▶"; const sp = wave.querySelectorAll("span"); for (const s of sp) s.style.background = "rgba(255,255,255,.4)"; }; } // Upload the recorded (and listened-to) voice only after the user confirms. async function obConfirmVoice() { const status = document.getElementById("obRecStatus"); if (!_recBlob) return; if (obData.voiceUploaded) return; // already uploaded, skip if (status) status.textContent = t('processing'); // IMPORTANT: send raw WAV bytes (not FormData) — server does raw[:4] to // sniff the RIFF header. FormData wraps the file in multipart boundaries // which makes raw[:4] != "RIFF" → "invalid audio". try { const buf = await _recBlob.arrayBuffer(); const r = await api("/api/upload_voice", { method: "POST", body: buf }); if (r.ok && r.url) { obData.voice_file_id = r.url; obData.voiceUploaded = true; obRender(); // re-render shows the uploaded confirmation card } else { if (status) status.textContent = t('voiceReady'); } } catch (e) { if (status) status.textContent = t('voiceReady'); } } function obTogglePlay() { const audio = document.getElementById("ob_voice"); const playBtn = document.getElementById("obVpPlay"); if (!audio) return; if (audio.paused) { audio.play().then(() => { playBtn.textContent = "■"; }).catch(() => {}); } else { audio.pause(); playBtn.textContent = "▶"; } } // ---- location step ---- function obLocHow() { const el = document.getElementById("obLocHowText"); if (el) el.classList.toggle("hidden"); } function obOpenTgLocate() { // Flow: hand off to the BOT so it sends the location-sharing keyboard. // We use Telegram's openTelegramLink to deep-link into the bot chat with // /start locate — the bot then sends the prompt itself (reliable, no // dependency on an in-flight HTTP request surviving WebApp close()). const tg = window.Telegram && window.Telegram.WebApp; const botUser = (window.TG_BOT_USERNAME) || "MyWinkyBot"; const link = "https://t.me/" + botUser + "?start=locate"; try { if (tg && tg.openTelegramLink) { tg.openTelegramLink(link); // remember that user is in location-flow so we can auto-advance on return try { sessionStorage.setItem("winky_loc_pending", "1"); } catch(e) {} // give it a beat, then close the WebApp so the user is in the bot chat setTimeout(() => { try { tg.close(); } catch (e) {} }, 300); return; } } catch (e) {} // fallback: just close (user can tap the bot) try { if (tg && tg.close) { tg.close(); return; } } catch (e) {} try { window.location.href = link; } catch (e2) {} } function obShowTgFallback() { const fb = document.getElementById("obLocTgFallback"); if (fb) fb.classList.remove("hidden"); } async function obAskLoc() { const btn = document.getElementById("obLocAllow"); const status = document.getElementById("obLocStatus"); if (!navigator.geolocation) { if (status) status.textContent = t('locNoGeo'); obShowTgFallback(); return; } if (btn) { btn.disabled = true; btn.textContent = t('locWorking'); } navigator.geolocation.getCurrentPosition( async (pos) => { obData.lat = pos.coords.latitude; obData.lon = pos.coords.longitude; // Reverse geocode via OpenStreetMap/Nominatim so the user sees // the actual detected city instead of a vague "location set". try { const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${encodeURIComponent(obData.lat)}&lon=${encodeURIComponent(obData.lon)}&accept-language=fa,en`; const r = await fetch(url, { headers: { "Accept": "application/json", } }); if (r.ok) { const j = await r.json(); const a = j.address || {}; const city = a.city || a.town || a.village || a.county || a.state || ""; const province = a.state || a.region || ""; if (city) obData.city = city; if (province) obData.province = province; // persist immediately so closing/reopening resumes correctly try { await api("/api/save_step", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step: 12, fields: { lat: obData.lat, lon: obData.lon, city: obData.city, province: obData.province, } }) }); } catch (e) {} if (status) { status.textContent = city ? `${t('locSet')} — ${city}` : t('locSet'); } } else { if (status) status.textContent = t('locSet'); } } catch (e) { if (status) status.textContent = t('locSet'); } if (btn) { btn.textContent = t('locAllow'); btn.disabled = false; } }, (err) => { // iOS Telegram Mini App (and desktop browsers without GPS) usually deny/block. // Show the Telegram-native location button as a fallback (works on iOS). if (status) status.textContent = t('locDenied'); if (btn) { btn.textContent = t('locAllow'); btn.disabled = false; } obShowTgFallback(); }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 60000 } ); } async function obFinish() { // ---- client-side required-field gate (prevent empty profiles entering the app) ---- const missing = []; if (!obData.name || !obData.name.trim()) missing.push(t('errName')); if (!obData.birthdate) missing.push(t('errBirth')); if (!obData.gender) missing.push(t('errGender')); if (!obData.photo_id) missing.push(t('errPhoto')); if (missing.length) { const err = document.getElementById("ob_err"); if (err) err.textContent = t('errFinish') + " " + missing.join("، "); return; } // show loading state on the Enter button while we save to the server const btn = document.querySelector(".ob-nav .ob-next"); if (btn) { btn.disabled = true; btn.dataset.label = btn.textContent; btn.innerHTML = ` ${t('saving')}`; } // If a voice was recorded but not yet uploaded (e.g. user tapped Next/Finish // right after stopping), make sure it's uploaded before we build the payload. if (_recBlob && !obData.voiceUploaded) { await obConfirmVoice(); } // build payload once const payload = { name: obData.name, birthdate: obData.birthdate, height: obData.height || 0, gender: obData.gender, looking_for: obData.looking_for, intent: JSON.stringify(obData.intent), lifestyle: JSON.stringify(obData.lifestyle), interests: JSON.stringify(obData.interests), city: obData.city, province: obData.province, bio: obData.bio, photo_id: obData.photo_id, photos: JSON.stringify(obData.photos), voice_file_id: obData.voice_file_id, registered: 1, lang: obData.lang || CUR_LANG, lat: obData.lat, lon: obData.lon, }; // save with retry (best-effort, but surface failure instead of silently dropping data) let saved = false; for (let attempt = 1; attempt <= 3 && !saved; attempt++) { try { await api("/api/me", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload) }); saved = true; } catch (e) { if (attempt < 3) await new Promise(r => setTimeout(r, 800 * attempt)); // backoff } } if (!saved) { // restore button + warn user, do NOT enter the app empty if (btn) { btn.disabled = false; btn.textContent = btn.dataset.label || t('enter'); } const err = document.getElementById("ob_err"); if (err) err.textContent = t('errSave'); return; } obHide(); // refresh feed with the new profile try { switchTab("discover"); } catch (e) { loadFeed(); } } function obSaveDraft() { // No-op: wizard progress is persisted server-side via /api/save_step. } function obLoadDraft() { // No-op: wizard progress is loaded from the server (/api/me -> step). return null; } // Dev helper: (re)open the onboarding wizard from anywhere (even after registering), // so development on the wizard is possible without wiping the DB. async function openWizard() { document.body.classList.add("ob-open"); const ob = document.getElementById("onboarding"); if (ob) ob.classList.remove("hidden"); // Hydrate from server so previously uploaded photos/fields are restored. // (Server is source of truth — never wipe progress on reopen.) try { const me = await api("/api/me"); const prof = (me && me.profile) ? me.profile : (me || {}); obData = { lang: (me && me.lang) || CUR_LANG, name: prof.name || "", birthdate: prof.birthdate || "", height: prof.height || 0, gender: prof.gender || "", looking_for: prof.looking_for || "", city: prof.city || "", province: prof.province || "", bio: prof.bio || "", photo_id: prof.photo_id || "", photos: [], voice_file_id: prof.voice_file_id || "", intent: [], lifestyle: {}, interests: [], }; if (prof.photos != null) { try { obData.photos = (typeof prof.photos === "string") ? JSON.parse(prof.photos) : prof.photos; } catch (e) { obData.photos = []; } } if (prof.intent != null) { try { obData.intent = (typeof prof.intent === "string") ? JSON.parse(prof.intent) : prof.intent; } catch (e) { obData.intent = []; } } if (prof.lifestyle != null) { try { obData.lifestyle = (typeof prof.lifestyle === "string") ? JSON.parse(prof.lifestyle) : prof.lifestyle; } catch (e) { obData.lifestyle = {}; } } if (prof.interests != null) { try { obData.interests = (typeof prof.interests === "string") ? JSON.parse(prof.interests) : prof.interests; } catch (e) { obData.interests = []; } } obStep = (me && typeof me.step === "number") ? me.step : 0; } catch (e) { // fallback: fresh data obData = { lang: CUR_LANG, name: "", birthdate: "", height: 0, gender: "", looking_for: "", city: "", province: "", bio: "", photo_id: "", photos: [], voice_file_id: "", intent: [], lifestyle: {}, interests: [], }; obStep = 0; } obRender(); } // Called from main.js when user is not registered. async function startOnboarding() { try { obShow(); } catch(e) {} // Determine language. // 1) ?lang= or #lang= URL param (explicit share link / dev override) // 2) Telegram user language (for first open) // 3) DB lang (if already registered) // 4) localStorage (previously chosen in THIS browser session) // 5) default 'fa' const params = new URLSearchParams(location.search); const hash = new URLSearchParams(location.hash.replace(/^#/, "")); let lang = params.get("lang") || hash.get("lang"); if (!lang && tg?.initDataUnsafe?.user?.language_code) { const lc = tg.initDataUnsafe.user.language_code.toLowerCase(); if (lc.startsWith("en")) lang = "en"; } if (!lang) { try { const lc = tg?.initDataUnsafe?.user?.language_code; if (lc) lang = lc.toLowerCase().startsWith("en") ? "en" : lang; } catch (e) {} } if (lang) setLang(lang); obStep = 0; const draftStep = obLoadDraft(); // no-op (server is source of truth) if (draftStep && draftStep > 0) obStep = draftStep; // Load the server profile LAST so the captured location (saved by the bot, // never written to the local draft) overlays any stale null lat/lon in the // draft. Then use the SERVER-computed step as the source of truth. try { const me = await api("/api/me"); if (me && me.lang) lang = me.lang; const prof = (me && me.profile) ? me.profile : (me || {}); if (lang) setLang(lang); if (me && typeof me.step === "number") obStep = me.step; // Hydrate ALL profile fields into obData so that Back/reopen // restores previously entered values into the forms. if (prof.name != null) obData.name = prof.name; if (prof.gender != null) obData.gender = prof.gender; if (prof.birthdate != null) obData.birthdate = prof.birthdate; if (prof.height != null) obData.height = prof.height; if (prof.looking_for != null) obData.looking_for = prof.looking_for; if (prof.bio != null) obData.bio = prof.bio; if (prof.city != null) obData.city = prof.city; if (prof.province != null) obData.province = prof.province; if (prof.photo_id != null) obData.photo_id = prof.photo_id; if (prof.lat != null) obData.lat = prof.lat; if (prof.lon != null) obData.lon = prof.lon; if (prof.voice_file_id != null) obData.voice_file_id = prof.voice_file_id; // Parse JSON arrays/objects from server // Server already parses these in /api/me, so they may be arrays OR strings. const _safeJson = (v, d) => { if (v == null) return d; return (typeof v === "string") ? JSON.parse(v) : v; }; obData.photos = _safeJson(prof.photos, []); obData.intent = _safeJson(prof.intent, []); obData.lifestyle = _safeJson(prof.lifestyle, {}); obData.interests = _safeJson(prof.interests, []); } catch (e) {} // Auto-advance past location step if user just returned from Telegram location flow // or if location was captured while on step 12 const locPending = sessionStorage.getItem("winky_loc_pending"); if (obData.lat != null && obData.lon != null) { if (locPending || obStep === 12) { obStep = 13; try { sessionStorage.removeItem("winky_loc_pending"); } catch(e) {} } } resumePendingPhoto(); // no-op (server holds photos) obRender(); }