// Onboarding flow: Sign Up (ToS + 18+) then multi-step profile wizard. // Driven by a state machine. The whole flow is rendered into #onboarding. const OB_TOTAL = 13; // language + signup + 11 wizard steps (incl. location) let obData = { terms_accepted: false, name: "", birthdate: "", 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() { console.log('[obRender] lang=' + CUR_LANG + ' step=' + obStep); 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) { // "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 === 5) { html = `
${obProgress(obStep)}

${t('wizBio')}

${t('wizBioDesc')}

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

${t('wizPhotos')}

${t('wizPhotosDesc')}

${obData.photos.map((u,i)=>`
`).join("")} ${(obData.photos.length < 6) ? `
+
` : ''}
${obData.photos.length}/6
`; } else if (obStep === 7) { // "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 === 8) { // "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 === 9) { // "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 === 10) { 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 === 11) { // 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 ? t('locSet') : ''}
${t('locHow')} ▾
`; } else if (obStep === 12) { html = `
${obProgress(obStep)}

${t('wizDone')}

${t('wizDoneDesc')}

`; } host.innerHTML = html.replace(/class="ob-card"/g, `class="ob-card ${obDir==='back'?'back':''}"`); if (obStep === 3) { 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; } 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; 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); } function obConfirmAgeOk() { const m = document.getElementById("obAgeModal"); if (m) m.remove(); 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 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 obNext() { // step 4 (looking_for): require at least one selection before advancing if (obStep === 4) { 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 7 (intent): require at least one selection before advancing if (obStep === 7) { if (obData.intent.length === 0) { const err = document.getElementById("ob_err"); if (err) err.textContent = t('errIntent'); return; } } // step 6 (photos): require at least one uploaded photo before advancing if (obStep === 6) { if (!obData.photo_id || obData.photos.length === 0) { const err = document.getElementById("ob_err"); if (err) err.textContent = t('errPhoto'); return; } } // step 11 (location): require a captured location before advancing if (obStep === 11) { 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 5: bio is optional, just capture it if (obStep === 5) obData.bio = document.getElementById("ob_bio").value.trim(); obStep++; obDir = "fwd"; 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(); } // After a file is picked, upload it immediately (no crop step). The crop UI // caused Telegram iOS to reload the mini app on return, which dropped the // in-flight upload. We skip crop entirely to keep the upload reliable. function obStartCrop(file) { if (!file) return; obUpload(file); } function obCloseCrop() { const m = document.getElementById("obCrop"); if (m) m.remove(); } // 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; let done = false; 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 recenter = () => { 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; const apply = () => { img.style.width = boxW() + "px"; img.style.height = boxH() + "px"; img.style.left = _crop.x + "px"; img.style.top = _crop.y + "px"; }; 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"; }; let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0; img.addEventListener("pointerdown", (e) => { dragging = true; sx = e.clientX; sy = e.clientY; ox = _crop.x; oy = _crop.y; img.setPointerCapture(e.pointerId); }); img.addEventListener("pointermove", (e) => { if (!dragging) return; _crop.x = ox + (e.clientX - sx); _crop.y = oy + (e.clientY - sy); recenter(); apply(); refreshApply(); }); img.addEventListener("pointerup", () => { dragging = false; }); const zoom = (d, cx, cy) => { const { natW, natH, contain, cover } = dims(); const minScale = cover, maxScale = cover * 4; // never allow black bars 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; } recenter(); apply(); refreshApply(); }; 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 }); // pinch zoom (two pointers) const pts = new Map(); stage.addEventListener("pointerdown", (e) => { pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); }); stage.addEventListener("pointermove", (e) => { if (!pts.has(e.pointerId)) return; pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pts.size === 2) { const [a, b] = [...pts.values()]; const dist = Math.hypot(a.x - b.x, a.y - b.y); if (initCrop._pd) { const r = stage.getBoundingClientRect(); const mid = { x: (a.x + b.x) / 2 - r.left, y: (a.y + b.y) / 2 - r.top }; zoom((dist - initCrop._pd) * 0.01, mid.x, mid.y); } initCrop._pd = dist; } }); stage.addEventListener("pointerup", (e) => { pts.delete(e.pointerId); initCrop._pd = null; }); stage.addEventListener("pointercancel", (e) => { pts.delete(e.pointerId); initCrop._pd = null; }); 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(); // Persist the cropped image synchronously (data URL) BEFORE the // async upload, so if Telegram reloads the mini app right after // this, resumePendingPhoto() can finish the upload. try { const r = new FileReader(); r.onload = () => { try { localStorage.setItem("winky_pending_photo", r.result); } catch(e){} }; r.readAsDataURL(cropped); } catch (e) {} 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"); if (add) add.style.display = "none"; // Persist the raw file as base64 BEFORE upload so a Telegram iOS reload // (triggered when returning from the photo picker) can resume the upload. try { const b64 = await fileToDataURL(file); localStorage.setItem("winky_pending_photo", b64); } catch (e) {} obSaveDraft(); // 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; } localStorage.removeItem("winky_pending_photo"); // upload done, clear pending obSaveDraft(); } } 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); }); } // Resume any upload that was interrupted by a Telegram iOS reload. async function resumePendingPhoto() { try { const b64 = localStorage.getItem("winky_pending_photo"); if (!b64) return; if (obData.photos.length >= 6) { localStorage.removeItem("winky_pending_photo"); return; } // convert data URL back to a Blob and upload const res = await fetch(b64); const blob = await res.blob(); const file = new File([blob], "photo.jpg", { type: "image/jpeg" }); await doUpload(file); // doUpload pushes to obData.photos on success localStorage.removeItem("winky_pending_photo"); obRender(); } catch (e) { localStorage.removeItem("winky_pending_photo"); } } // 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"); 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; } } } 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"); } // 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 (status) status.textContent = t('processing'); const fd = new FormData(); fd.append("file", _recBlob, "voice.wav"); try { const r = await api("/api/upload_voice", { method: "POST", body: fd }); if (r.ok && r.url) { obData.voice_file_id = r.url; obData.voiceUploaded = true; // persisted flag -> survives re-render (e.g. language switch) 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() { // Open the bot chat via Telegram's native link. With the bot configured as // a Mini App (Menu Button set in BotFather), Telegram keeps the web app // alive in the background (minimized) when switching to the chat, so the // user's session is preserved and they can return to it. const botUser = (window.TG_BOT_USERNAME) || "MyWinkyBot"; const url = "https://t.me/" + botUser + "?start=locate"; try { if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.openTelegramLink) { window.Telegram.WebApp.openTelegramLink(url); return; } } catch (e) {} window.open(url, "_blank"); } 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( (pos) => { obData.lat = pos.coords.latitude; obData.lon = pos.coords.longitude; 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')}`; } // build payload once const payload = { name: obData.name, birthdate: obData.birthdate, 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() { try { localStorage.setItem("winky_ob_draft", JSON.stringify({ data: obData, step: obStep })); } catch (e) {} } function obLoadDraft() { try { const raw = localStorage.getItem("winky_ob_draft"); if (!raw) return null; const d = JSON.parse(raw); if (d && d.data && typeof d.data === "object") Object.assign(obData, d.data); return (d && typeof d.step === "number") ? d.step : null; } catch (e) { 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. function openWizard() { document.body.classList.add("ob-open"); const ob = document.getElementById("onboarding"); if (ob) ob.classList.remove("hidden"); // fully-initialised data so the inputs don't render "undefined" obData = { lang: CUR_LANG, name: "", birthdate: "", 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() { // Determine language. Priority: // 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 me = await api("/api/me"); if (me && me.lang) lang = me.lang; // preload any location already shared via Telegram (iOS fallback path) const prof = (me && me.profile) ? me.profile : (me || {}); if (prof && prof.lat != null && prof.lon != null) { obData.lat = prof.lat; obData.lon = prof.lon; } } catch (e) {} } if (!lang) { try { lang = localStorage.getItem("winky_lang"); } catch (e) {} } if (lang) setLang(lang); obStep = 0; const draftStep = obLoadDraft(); // restore any in-progress wizard data if (draftStep && draftStep > 0) obStep = draftStep; resumePendingPhoto(); // resume any upload interrupted by an iOS reload obRender(); }