Files
Learn_System/frontend/js/admin/sections/shop.js
T
Maxim Dolgolyov 1b78f675f8 feat(shop): компактный UX вкладки Магазин — статы-строка, фильтр, поиск
- 4 крупные карточки статистики → компактная строка stat-пиллов
- тулбар: фильтр по типу + поиск по названию + счётчик (N из M)
- таблица: иконка-чип по типу + название с описанием в одной ячейке,
  цветные бейджи типов, колонка ID убрана (id ушёл в подпись)
- состояния «Нет товаров» / «Ничего не найдено»

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:14:55 +03:00

235 lines
10 KiB
JavaScript

'use strict';
/* admin → shop section: items + purchases */
(function () {
'use strict';
let inited = false;
let _shopItems = [];
let _filterType = '';
let _search = '';
const TYPE_LABELS = { frame:'Рамка', title:'Титул', theme:'Тема', effect:'Эффект', background:'Фон' };
const TYPE_ICONS = { frame:'square', title:'award', background:'image', effect:'sparkles', theme:'palette' };
const typeCls = t => ['frame','title','background','effect'].includes(t) ? 't-' + t : 't-other';
const typeIcon = t => TYPE_ICONS[t] || 'tag';
async function load() {
try {
const [stats, items] = await Promise.all([
LS.adminShopStats(),
LS.adminShopGetItems()
]);
const topName = stats.topItems?.[0]?.name || '—';
const stat = (cls, icon, ic, val, label, sm) => `
<div class="shop-stat">
<span class="shop-stat-ic" style="background:${ic}"><i data-lucide="${icon}" style="color:${cls}"></i></span>
<span class="shop-stat-txt">
<span class="shop-stat-v${sm ? ' sm' : ''}" style="color:${cls}">${val}</span>
<span class="shop-stat-l">${label}</span>
</span>
</div>`;
document.getElementById('shop-stats-grid').innerHTML =
stat('var(--violet)', 'shopping-bag', 'rgba(155,93,229,0.12)', `${stats.activeItems}/${stats.totalItems}`, 'товаров активно') +
stat('var(--cyan)', 'receipt', 'rgba(6,214,224,0.12)', stats.totalPurchases, 'покупок') +
stat('var(--green)', 'coins', 'rgba(6,214,100,0.12)', stats.totalCoinsInCirculation, 'монет в обороте') +
stat('#d98a17', 'star', 'rgba(255,179,71,0.16)', esc(topName), 'топ-товар', true);
_shopItems = items;
renderShopItems();
if (window.lucide) lucide.createIcons();
} catch(e) {
document.getElementById('shop-stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function shopApplyFilters() {
_filterType = document.getElementById('shop-filter-type')?.value || '';
_search = (document.getElementById('shop-search')?.value || '').trim().toLowerCase();
renderShopItems();
}
function renderShopItems() {
const body = document.getElementById('shop-items-body');
const countEl = document.getElementById('shop-count');
const filtered = _shopItems.filter(it =>
(!_filterType || it.type === _filterType) &&
(!_search || (it.name || '').toLowerCase().includes(_search))
);
if (countEl) countEl.textContent = filtered.length === _shopItems.length
? `${_shopItems.length} товаров`
: `${filtered.length} из ${_shopItems.length}`;
if (!_shopItems.length) { body.innerHTML = '<tr><td colspan="6" class="empty">Нет товаров</td></tr>'; return; }
if (!filtered.length) { body.innerHTML = '<tr><td colspan="6" class="empty">Ничего не найдено</td></tr>'; return; }
body.innerHTML = filtered.map(it => `<tr>
<td>
<div class="shop-cell">
<span class="shop-ic ${typeCls(it.type)}"><i data-lucide="${typeIcon(it.type)}"></i></span>
<span class="shop-cell-txt">
<strong>${esc(it.name)}</strong>
<span class="shop-cell-sub">${it.description ? esc(it.description) : '#' + it.id}</span>
</span>
</div>
</td>
<td><span class="shop-type-badge ${typeCls(it.type)}">${TYPE_LABELS[it.type] || esc(it.type)}</span></td>
<td class="shop-price">${it.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:#d98a17"></i></td>
<td>${it.sold_count || 0}</td>
<td>
<label class="adm-toggle">
<input type="checkbox" ${it.is_active ? 'checked' : ''} onchange="shopAdminToggleActive(${it.id}, this.checked)" />
<span class="track"></span><span class="thumb"></span>
</label>
</td>
<td>
<button class="btn-edit-q" onclick="shopAdminEditItem(${it.id})">Ред.</button>
<button class="btn-del-q" onclick="shopAdminDeleteItem(${it.id})">Удалить</button>
</td>
</tr>`).join('');
if (window.lucide) lucide.createIcons();
}
const TYPE_OPTIONS = [
{ v: 'frame', l: 'Рамка' },
{ v: 'title', l: 'Титул' },
{ v: 'background', l: 'Фон' },
{ v: 'effect', l: 'Эффект' },
];
/* Open the add/edit item modal. item = null → create, object → edit. */
function openItemModal(item) {
const isEdit = !!item;
const dataStr = item && item.data
? (typeof item.data === 'string' ? item.data : JSON.stringify(item.data))
: '';
const body = document.createElement('div');
body.innerHTML = `
<div class="adm-form-row">
<div class="adm-form-group" style="flex:1;min-width:160px">
<label>Название</label>
<input type="text" id="shop-f-name" placeholder="Название товара" />
</div>
<div class="adm-form-group" style="flex:1;min-width:120px">
<label>Тип</label>
<select id="shop-f-type">
${TYPE_OPTIONS.map(o => `<option value="${o.v}">${o.l}</option>`).join('')}
</select>
</div>
<div class="adm-form-group" style="width:96px">
<label>Цена</label>
<input type="number" id="shop-f-price" min="0" value="100" />
</div>
</div>
<div class="adm-form-row">
<div class="adm-form-group" style="flex:1">
<label>Описание</label>
<textarea id="shop-f-desc" rows="2" placeholder="Описание товара"></textarea>
</div>
</div>
<div class="adm-form-row" style="margin-bottom:0">
<div class="adm-form-group" style="flex:1;min-width:140px">
<label>Иконка (emoji/код)</label>
<input type="text" id="shop-f-icon" placeholder="SVG-код или эмодзи" />
</div>
<div class="adm-form-group" style="flex:1;min-width:140px">
<label>Данные (JSON)</label>
<input type="text" id="shop-f-data" placeholder='{"key":"value"}' />
</div>
<div class="adm-form-group">
<label>Активен</label>
<label class="adm-toggle">
<input type="checkbox" id="shop-f-active" checked />
<span class="track"></span><span class="thumb"></span>
</label>
</div>
</div>`;
const $ = sel => body.querySelector(sel);
$('#shop-f-name').value = item?.name || '';
$('#shop-f-type').value = item?.type || 'frame';
$('#shop-f-price').value = item?.price ?? 100;
$('#shop-f-desc').value = item?.description || '';
$('#shop-f-icon').value = item?.icon || '';
$('#shop-f-data').value = dataStr;
$('#shop-f-active').checked = item ? !!item.is_active : true;
let saving = false;
const m = LS.modal({
title: isEdit ? ('Редактировать товар #' + item.id) : 'Добавить товар',
content: body,
size: 'md',
actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Сохранить', primary: true, id: 'shop-save-btn', onClick: async () => {
if (saving) return;
const payload = {
name: $('#shop-f-name').value.trim(),
type: $('#shop-f-type').value,
price: parseInt($('#shop-f-price').value, 10) || 0,
description: $('#shop-f-desc').value.trim(),
icon: $('#shop-f-icon').value.trim(),
data: $('#shop-f-data').value.trim() || null,
is_active: $('#shop-f-active').checked ? 1 : 0,
};
if (!payload.name) { m.setError('Введите название'); return; }
if (payload.data) {
try { JSON.parse(payload.data); }
catch { m.setError('Поле «Данные» — некорректный JSON'); return; }
}
saving = true;
const btn = document.getElementById('shop-save-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Сохранение…'; }
try {
if (isEdit) { await LS.adminShopUpdateItem(item.id, payload); LS.toast('Товар обновлён', 'success'); }
else { await LS.adminShopCreateItem(payload); LS.toast('Товар создан', 'success'); }
m.close();
inited = false;
await load();
inited = true;
} catch(e) {
m.setError('Ошибка: ' + e.message);
saving = false;
if (btn) { btn.disabled = false; btn.textContent = 'Сохранить'; }
}
} },
],
});
setTimeout(() => $('#shop-f-name')?.focus(), 80);
}
function shopAdminCreateItem() { openItemModal(null); }
function shopAdminEditItem(id) {
const it = _shopItems.find(i => i.id === id);
if (it) openItemModal(it);
}
async function shopAdminDeleteItem(id) {
if (!await LS.confirm('Все покупки этого товара будут удалены.', { title: 'Удалить товар?', confirmText: 'Удалить', danger: true })) return;
try {
await LS.adminShopDeleteItem(id);
LS.toast('Товар удалён', 'success');
inited = false;
await load();
inited = true;
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function shopAdminToggleActive(id, active) {
try {
await LS.adminShopUpdateItem(id, { is_active: active ? 1 : 0 });
LS.toast(active ? 'Товар активирован' : 'Товар деактивирован', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose onclick handlers
window.shopAdminCreateItem = shopAdminCreateItem;
window.shopAdminEditItem = shopAdminEditItem;
window.shopAdminDeleteItem = shopAdminDeleteItem;
window.shopAdminToggleActive = shopAdminToggleActive;
window.shopApplyFilters = shopApplyFilters;
window.AdminSections = window.AdminSections || {};
window.AdminSections.shop = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();