ДИЗАЙН-СИСТЕМА в 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 кнопок.
201 lines
8.7 KiB
JavaScript
201 lines
8.7 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; |