Бэк уже отдает prefs в публичном /users/:id, но фронт не доставал.
UserProfileView:
- Принимает u.prefs.{drinks,activities,purposes}
- Словари вынесены в глобальные DRINKS/ACTIVITIES/PURPOSES (используются ProfileView и UserProfileView)
- Рендерит три секции card-b с .chip.on: 'Что пьёт' / 'Чем занимается' / 'Цели'
- Скрывает секцию если массив пуст
342 lines
16 KiB
JavaScript
342 lines
16 KiB
JavaScript
// 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;
|
||
if (!cid) 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;
|
||
} |