buhapp-telegram/public/js/views/profile.js

345 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Profile view (свой профиль)
const ProfileView = {
drinks: new Set(),
activities: new Set(),
purposes: new Set(),
anonymous: false,
DRINKS: [
['beer', '🍺 Пиво'], ['wine', '🍷 Вино'], ['whiskey', '🥃 Виски'], ['vodka', '🍸 Водка'],
['cocktail', '🍹 Коктейль'], ['rum', '🥃 Ром'], ['gin', '🍸 Джин'], ['tequila', '🌵 Текила'],
['champagne', '🍾 Шампанское'], ['non_alcoholic', '🥤 Безалкогольное'],
],
ACTIVITIES: [
['walk', '🚶 Прогулка'], ['dinner', '🍽️ Ужин'], ['bar', '🍻 Бар'], ['cafe', '☕ Кафе'],
['travel', '✈️ Путешествие'], ['movie', '🎬 Кино'], ['concert', '🎵 Концерт'],
['sport', '⚽ Спорт'], ['gaming', '🎮 Игры'], ['reading', '📚 Чтение'],
],
PURPOSES: [
['chat', '💬 Пообщаться'], ['drink', '🍻 Выпить'], ['walk', '🌳 Погулять'],
['dinner', '🍽️ Поужинать'], ['travel', '✈️ Поехать'], ['friendship', '🤝 Дружба'],
['relationship', '❤️ Отношения'],
],
async render(root) {
try {
const me = await API.Auth.me();
API.Store.user = me.user;
const prefs = me.prefs || {};
this.drinks = new Set(prefs.drinks || []);
this.activities = new Set(prefs.activities || []);
this.purposes = new Set(prefs.purposes || []);
this.renderForm(root, me.user);
} catch (e) {
root.innerHTML = '<div class="view"><div class="empty">Не удалось загрузить: ' + e.message + '</div></div>';
}
},
renderForm(root, u) {
root.innerHTML = `
<div class="view">
<h1 style="margin:0 0 14px;font-size:22px;">👤 Мой профиль</h1>
<!-- аватар -->
<div class="card-b" style="display:flex;align-items:center;gap:14px;">
<div class="avatar-b lg" id="p-avatar">${escapeHtml(((u.name || u.email || '?')[0] || '?').toUpperCase())}</div>
<div style="flex:1;">
<div style="font-weight:600;font-size:16px;">${escapeHtml(u.name || 'Без имени')}</div>
${u.telegram_id ? `<div style="font-size:13px;color:var(--text-dim);">@${escapeHtml(u.username || '')}</div>` : `<div style="font-size:13px;color:var(--text-dim);">${escapeHtml(u.email || '')}</div>`}
<input type="file" id="p-photo-input" accept="image/jpeg,image/png,image/webp" style="display:none;">
<button type="button" class="btn-b ghost tiny" id="p-upload" style="margin-top:8px;">📷 Загрузить фото</button>
</div>
</div>
<!-- поля -->
<div class="row-list" style="margin-top:14px;">
<label class="field-b">
<span>Имя</span>
<input class="ctrl" id="p-name" value="${escapeHtml(u.name || '')}" placeholder="Как тебя зовут">
<div class="hint mute" id="h-name"></div>
</label>
<label class="field-b">
<span>Город</span>
<input class="ctrl" id="p-city" placeholder="Москва" value="${escapeHtml(u.city || '')}">
</label>
<label class="field-b">
<span>О себе</span>
<textarea class="ctrl" id="p-bio" rows="3" placeholder="Пару слов о себе" style="resize:none;">${escapeHtml(u.bio || '')}</textarea>
<div class="hint mute" id="h-bio" style="text-align:right;font-size:11px;"></div>
</label>
<label class="field-b">
<span>Пол</span>
<select class="ctrl" id="p-gender" style="font:15px/1.2 inherit;padding:13px 14px;">
<option value="">Не указан</option>
<option value="m" ${u.gender === 'm' ? 'selected' : ''}>Мужской</option>
<option value="f" ${u.gender === 'f' ? 'selected' : ''}>Женский</option>
<option value="o" ${u.gender === 'o' ? 'selected' : ''}>Другое</option>
</select>
</label>
</div>
<!-- чипы (drink/activity/purpose) -->
<div class="row-list" style="margin-top:14px;">
<div class="card-b">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:10px;">🍻 Что пью</div>
<div class="chip-list" id="p-drinks"></div>
</div>
<div class="card-b">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:10px;">🎯 Чем занимаюсь</div>
<div class="chip-list" id="p-activities"></div>
</div>
<div class="card-b">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:10px;">💡 Цели</div>
<div class="chip-list" id="p-purposes"></div>
</div>
</div>
<button class="btn-b" id="p-save" style="margin-top:14px;">Сохранить</button>
</div>
`;
this.renderChips('p-drinks', this.DRINKS, this.drinks);
this.renderChips('p-activities', this.ACTIVITIES, this.activities);
this.renderChips('p-purposes', this.PURPOSES, this.purposes);
// upload photo
document.getElementById('p-upload').onclick = () => document.getElementById('p-photo-input').click();
document.getElementById('p-photo-input').onchange = async (e) => {
const f = e.target.files[0];
if (!f) return;
if (f.size > 8 * 1024 * 1024) { App._toast('Файл больше 8 МБ', 'err'); return; }
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) { App._toast('Только JPEG/PNG/WebP', 'err'); return; }
const fd = new FormData();
fd.append('photo', f);
const btn = document.getElementById('p-upload');
const orig = btn.textContent;
btn.textContent = '⏳ Загружаем…';
btn.classList.add('load');
try {
const r = await fetch(API_BASE + '/api/v1/me/photo', { method: 'POST', body: fd, credentials: 'include' });
if (!r.ok) throw new Error((await r.json()).error || 'HTTP ' + r.status);
App._toast('✓ Фото загружено', 'ok');
} catch (e) {
App._toast('⚠️ ' + e.message, 'err');
} finally {
btn.textContent = orig;
btn.classList.remove('load');
}
};
// live counters
const bio = document.getElementById('p-bio');
const counter = document.getElementById('h-bio');
function updBio() {
const n = bio.value.length;
counter.textContent = n + ' / 500';
counter.className = 'hint ' + (n > 450 ? 'err' : 'mute');
}
bio.addEventListener('input', updBio);
updBio();
document.getElementById('p-save').onclick = () => this.save();
},
renderChips(elId, items, selected) {
const el = document.getElementById(elId);
if (!el) return;
items.forEach(([key, label]) => {
const chip = document.createElement('span');
chip.className = 'chip' + (selected.has(key) ? ' on' : '');
chip.textContent = label;
chip.onclick = () => {
try {
if (selected.has(key)) selected.delete(key); else selected.add(key);
chip.classList.toggle('on');
TG_APP.haptic?.('selection');
} catch (e) { console.warn('chip click err', e); }
};
el.appendChild(chip);
});
},
async save() {
const btn = document.getElementById('p-save');
const orig = btn.textContent;
btn.className = 'btn-b load';
btn.textContent = '⏳ Сохраняем…';
try {
await API.Auth.updateMe({
name: document.getElementById('p-name').value.trim(),
city: document.getElementById('p-city').value.trim(),
bio: document.getElementById('p-bio').value.trim(),
gender: document.getElementById('p-gender').value,
});
await API.Auth.updatePrefs({
drinks: [...this.drinks],
activities: [...this.activities],
purposes: [...this.purposes],
});
TG_APP.haptic?.('notification');
btn.className = 'btn-b ok';
btn.textContent = '✓ Готово!';
App._toast('Профиль обновлён', 'ok');
setTimeout(() => {
btn.className = 'btn-b';
btn.textContent = orig;
}, 1500);
} catch (e) {
btn.className = 'btn-b err';
btn.textContent = '⚠️ ' + (e.message || 'Ошибка');
App._toast('Не удалось сохранить', 'err');
const bad = (e.message || '').includes('name') ? 'p-name' : (e.message || '').includes('gender') ? 'p-gender' : null;
if (bad) document.getElementById(bad)?.classList.add('bad');
setTimeout(() => {
btn.className = 'btn-b';
btn.textContent = orig;
}, 2000);
}
},
};
window.ProfileView = ProfileView;
// Словари — общие для ProfileView и UserProfileView
const DRINKS = [
['beer', '🍺 Пиво'], ['wine', '🍷 Вино'], ['whiskey', '🥃 Виски'], ['vodka', '🍸 Водка'],
['cocktail', '🍹 Коктейль'], ['rum', '🥃 Ром'], ['gin', '🍸 Джин'], ['tequila', '🌵 Текила'],
['champagne', '🍾 Шампанское'], ['non_alcoholic', '🥤 Безалкогольное'],
];
const ACTIVITIES = [
['walk', '🚶 Прогулка'], ['dinner', '🍽️ Ужин'], ['bar', '🍻 Бар'], ['cafe', '☕ Кафе'],
['travel', '✈️ Путешествие'], ['movie', '🎬 Кино'], ['concert', '🎵 Концерт'],
['sport', '⚽ Спорт'], ['gaming', '🎮 Игры'], ['reading', '📚 Чтение'],
];
const PURPOSES = [
['chat', '💬 Пообщаться'], ['drink', '🍻 Выпить'], ['walk', '🌳 Погулять'],
['dinner', '🍽️ Поужинать'], ['travel', '✈️ Поехать'], ['friendship', '🤝 Дружба'],
];
// UserProfileView — чужой профиль (read-only) — открывается по тапу на маркер на карте
const UserProfileView = {
userId: null,
userName: null,
data: null,
async render(root, params) {
this.userId = params.userId;
this.userName = params.name || 'Пользователь';
console.log('[UserProfile] render start, userId:', this.userId, 'name:', this.userName, 'params:', JSON.stringify(params));
root.innerHTML = `
<div class="view">
<h1 style="margin:0 0 14px;font-size:22px;">👤 ${escapeHtml(this.userName)}</h1>
<div id="up-content">${'<div class="empty-b">⏳ Загружаю…</div>'}</div>
</div>
`;
try {
const [user, stats] = await Promise.all([
API.UserApi.get(this.userId),
API.ReviewApi.stats(this.userId).catch(() => ({})),
]);
console.log('[UserProfile] loaded: user:', JSON.stringify(user).slice(0,200), 'stats:', JSON.stringify(stats));
this.data = user.user || user;
const u = this.data;
const init = ((u.name || u.email || '?')[0] || '?').toUpperCase();
const rating = stats.rating_avg ? Number(stats.rating_avg).toFixed(1) : '—';
const reviewsCount = stats.rating_count || 0;
// prefs чужие — подтянем из ответа /users/:id (prefs включены в публичный ответ)
const prefs = u.prefs || {};
const drinkLabels = DRINKS.filter(([k]) => (prefs.drinks || []).includes(k)).map(([, l]) => l);
const activityLabels = ACTIVITIES.filter(([k]) => (prefs.activities || []).includes(k)).map(([, l]) => l);
const purposeLabels = PURPOSES.filter(([k]) => (prefs.purposes || []).includes(k)).map(([, l]) => l);
document.getElementById('up-content').innerHTML = `
<!-- Аватар и имя -->
<div class="card-b" style="display:flex;align-items:center;gap:14px;">
<div class="avatar-b lg" style="${u.photo ? `background-image:url(${u.photo});background-size:cover;color:transparent;` : ''}">${u.photo ? '' : init}</div>
<div style="flex:1;">
<div style="font-weight:700;font-size:18px;">${escapeHtml(u.name || '')}</div>
${u.city ? `<div style="font-size:13px;color:var(--text-dim);">📍 ${escapeHtml(u.city)}</div>` : ''}
${u.age ? `<div style="font-size:13px;color:var(--text-dim);">🎂 ${u.age} лет</div>` : ''}
${reviewsCount > 0 ? `<div style="font-size:13px;color:var(--warn);margin-top:4px;">★ ${rating} · ${reviewsCount} ${pluralRu(reviewsCount, 'отзыв', 'отзыва', 'отзывов')}</div>` : ''}
</div>
</div>
<!-- О себе -->
${u.bio ? `
<div class="card-b" style="margin-top:12px;">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:6px;">О себе</div>
<div style="font-size:14px;line-height:1.4;">${escapeHtml(u.bio)}</div>
</div>
` : ''}
<!-- Чипы (drinks/activities/purposes) -->
${(drinkLabels.length || activityLabels.length || purposeLabels.length) ? `
<div class="row-list" style="margin-top:12px;">
${drinkLabels.length ? `
<div class="card-b">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:8px;">🍻 Что пьёт</div>
<div class="chip-list">${drinkLabels.map((l) => `<span class="chip on">${escapeHtml(l)}</span>`).join('')}</div>
</div>
` : ''}
${activityLabels.length ? `
<div class="card-b">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:8px;">🎯 Чем занимается</div>
<div class="chip-list">${activityLabels.map((l) => `<span class="chip on">${escapeHtml(l)}</span>`).join('')}</div>
</div>
` : ''}
${purposeLabels.length ? `
<div class="card-b">
<div style="font-size:12px;font-weight:600;color:var(--text-dim);margin-bottom:8px;">💡 Цели</div>
<div class="chip-list">${purposeLabels.map((l) => `<span class="chip on">${escapeHtml(l)}</span>`).join('')}</div>
</div>
` : ''}
</div>
` : ''}
<!-- Действия -->
<div class="row-list" style="margin-top:14px;">
<button class="btn-b" id="up-message">💬 Написать</button>
${reviewsCount > 0 ? `<button class="btn-b ghost" id="up-reviews">⭐ Все отзывы</button>` : ''}
</div>
`;
const msgBtn = document.getElementById('up-message');
if (msgBtn) msgBtn.onclick = async () => {
msgBtn.className = 'btn-b load';
msgBtn.textContent = '⏳ Открываем…';
try {
const r = await API.ChatApi.ensureChat(this.userId);
const cid = r.chat_id || r.chat?.id || r.id;
if (!cid) {
console.error('[UserProfile] no chat id in response', r);
throw new Error('chat not created');
}
App.openChat(cid, this.userId, u.name);
} catch (e) {
msgBtn.className = 'btn-b err';
msgBtn.textContent = '⚠️ ' + (e.message || 'Ошибка');
App._toast('Не удалось открыть чат', 'err');
setTimeout(() => { msgBtn.className = 'btn-b'; msgBtn.textContent = '💬 Написать'; }, 2000);
}
};
const revBtn = document.getElementById('up-reviews');
if (revBtn) revBtn.onclick = () => UserReviewsView.render(root, { userId: this.userId, userName: u.name });
} catch (e) {
console.error('[UserProfile] load failed:', e && e.message, 'userId:', this.userId, 'stack:', e && e.stack);
// ship to backend debug log
try {
fetch('/api/v1/debug/log', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind: 'UserProfileError', msg: String(e && e.message), userId: String(this.userId), stack: String(e && e.stack || '').slice(0, 500) }) });
} catch {}
document.getElementById('up-content').innerHTML = `<div class="empty-b">⚠️ Не удалось загрузить</div>`;
App._toast('Ошибка загрузки профиля', 'err');
}
},
};
window.UserProfileView = UserProfileView;
function pluralRu(n, one, few, many) {
const mod10 = n % 10, mod100 = n % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
return many;
}