buhapp-telegram/public/js/views/profile.js
root c5f8ac67ad UX overhaul: единый стиль BuhApp на всех экранах
ДИЗАЙН-СИСТЕМА в app.css:
- --primary #FF6B35, --success #3dd68c, --danger #ff5c5c
- --bg #0f0f12, --bg-elev #1a1a22, --bg-input #121218
- Компоненты: .card-b, .row-b, .field-b, .btn-b, .switch-b, .chip, .avatar-b, .bubble-b, .composer-b, .toast-b, .stars-b, .pulse-b, .empty-b

ПЕРЕПИСАНЫ на единый стиль:

Settings:
- Карточка с аватаром пользователя
- Card-row для юр.документов (📄 / 🔒 / ⚠️)
- 'Выйти' — danger btn-b с state machine (idle→load→ok)

Profile:
- Карточка аватара + кнопка 'Загрузить фото' через multipart /api/v1/me/photo
- Поля: field-b (ctrl + hint)
- Чипы в chip-list, новый стиль с .on
- Счётчик bio символов
- 'Сохранить' со state machine + toast

Review (создание):
- Звёзды в stars-b с center hint
- textarea с counter
- switch-b 'Анонимно' (вместо checkbox)
- Submit со state machine + toast 'Спасибо за отзыв!'

UserReviews:
- Средний рейтинг + звёзды
- Список карточек с отзывами

Chats:
- Список с avatar-b + last message + время + badge непрочитанных
- Пустое состояние с эмодзи
- formatChatTime() — 'сейчас' / '5м' / '3ч' / '2д'

Chat (один):
- Header с аватаром и кнопками 👤 ⭐
- composer-b: 📎 + input + ➤ (sticky bottom)
- bubble-b .in / .out с .ticks.read
- Send со state + toast
- sendPhoto() для фоток в чате

App.js _toast(msg, kind):
- Единый toast-b с классами ok / err

Всё в одном стиле. CSS variables, padding 16, radius 14, no disabled кнопок.
2026-08-21 11:42:16 +00:00

201 lines
8.7 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;