// 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
زبان را انتخاب کنید !
فارسی · FA
English · EN
`;
} else if (obStep === 1) {
html = `
${t('welcome')}
${t('welcomeDesc')}
${t('terms')}
${t('back')}
${t('createAccount')}
`;
} else if (obStep === 2) {
html = `
`;
} 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("")}
${t('back')}
${t('next')}
`;
} 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) => `
${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')}
${t('back')}
${t('next')}
`;
} else if (obStep === 5) {
html = `
${obProgress(obStep)}
${t('wizBio')}
${t('wizBioDesc')}
${t('back')}
${t('skip')} ›
${t('next')}
`;
} else if (obStep === 6) {
html = `
${obProgress(obStep)}
${t('wizPhotos')}
${t('wizPhotosDesc')}
${obData.photos.map((u,i)=>`
✕
${u===obData.photo_id?t('main'):t('pick')}
`).join("")}
${(obData.photos.length < 6) ? `
+
` : ''}
${obData.photos.length}/6
${t('back')}
${t('next')}
`;
} 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 => `
${t('intent_'+k)}
✓
`).join("");
html = `
${obProgress(obStep)}
${t('wizIntent')}
${t('wizIntentDesc')}
${cards}
i ${t('intentNote')}
${t('back')}
${t('next')}
`;
} 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 => `${t('life_'+s.key+'_'+o)} `).join("")}
`).join("");
html = `
${obProgress(obStep)}
${t('wizLife')}
${t('wizLifeDesc')}
${secHtml}
${t('back')}
${t('next')}
`;
} 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]) => `${emo} ${label} `).join("")}
`).join("");
const cnt = obData.interests.length;
html = `
${obProgress(obStep)}
${t('wizInterests')}
${t('wizInterestsDesc')}
${catHtml}
${t('back')}
${t('skip')} ›
${cnt}/6 ${t('selected')}
${t('next')}
`;
} 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 ? '✓' : '🎙'}
${hasVoice ? t('voiceReady') : t('tapToRecord')}
${t('reRecord')}
${t('confirmVoice')}
✓
${t('voiceUploaded')}
${t('back')}
${t('skip')} ›
${t('next')}
`;
} 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')}
${t('locAllow')}
${hasGPS ? t('locSet') : ''}
${t('locHow')} ▾
${t('locHowText')}
${t('locTgFallback')}
${t('back')}
${t('next')}
`;
} else if (obStep === 12) {
html = `
${obProgress(obStep)}
${t('wizDone')}
${t('wizDoneDesc')}
${t('back')}
${t('enter')}
`;
}
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')}
${t('ageConfirmBtn')}
${t('ageCancelBtn')}
`;
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();
}