0b0c113181
Таблица заменена на сетку карточек, сгруппированных по типам (Рамки/Титулы/Фоны/Эффекты) с заголовками и счётчиками. Каждая карточка показывает настоящий вид товара: - frame → кольцо аватара по data.css - background → .bg-preview.bg-<slug> (тот же CSS, что у клиента) - title → текст титула в его цвете (data.text/color) - effect → анимация pulse / иконка-фоллбэк Фильтр по типу, поиск и счётчик сохранены; неактивные товары притушены; удаление компактной иконкой. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
280 lines
12 KiB
JavaScript
280 lines
12 KiB
JavaScript
'use strict';
|
|
/* admin → shop section: items + purchases */
|
|
(function () {
|
|
'use strict';
|
|
let inited = false;
|
|
let _shopItems = [];
|
|
let _filterType = '';
|
|
let _search = '';
|
|
|
|
const TYPE_ICONS = { frame:'square', title:'award', background:'image', effect:'sparkles', theme:'palette' };
|
|
const typeIcon = t => TYPE_ICONS[t] || 'tag';
|
|
const GROUP_ORDER = [
|
|
{ type: 'frame', label: 'Рамки' },
|
|
{ type: 'title', label: 'Титулы' },
|
|
{ type: 'background', label: 'Фоны' },
|
|
{ type: 'effect', label: 'Эффекты' },
|
|
];
|
|
|
|
function parseData(it) {
|
|
if (!it || it.data == null) return {};
|
|
if (typeof it.data === 'object') return it.data;
|
|
try { return JSON.parse(it.data); } catch { return {}; }
|
|
}
|
|
|
|
/* Real visual preview by item type (uses the same data the client renders). */
|
|
function itemPreview(it) {
|
|
const d = parseData(it);
|
|
if (it.type === 'background') {
|
|
const slug = String(d.slug || 'none').replace(/[^a-z0-9_-]/gi, '');
|
|
return `<div class="sh-prev bg-preview bg-${slug}"></div>`;
|
|
}
|
|
if (it.type === 'title') {
|
|
const color = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(d.color || '') ? d.color : '#9B5DE5';
|
|
return `<div class="sh-prev"><span class="sh-prev-title" style="color:${color}">${esc(d.text || it.name || '')}</span></div>`;
|
|
}
|
|
if (it.type === 'frame') {
|
|
const css = String(d.css || '').replace(/"/g, '"');
|
|
return `<div class="sh-prev"><span class="sh-av" style="${css}"></span></div>`;
|
|
}
|
|
if (it.type === 'effect') {
|
|
const fx = d.effect === 'pulse' ? ' fx-pulse' : '';
|
|
const ic = d.effect === 'pulse' ? '' : `<i data-lucide="${typeIcon(it.type)}" class="sh-fx-ic" style="position:absolute"></i>`;
|
|
return `<div class="sh-prev"><span class="sh-av${fx}"></span>${ic}</div>`;
|
|
}
|
|
return `<div class="sh-prev"><i data-lucide="${typeIcon(it.type)}" class="sh-fx-ic"></i></div>`;
|
|
}
|
|
|
|
function cardHTML(it) {
|
|
return `<div class="sh-card${it.is_active ? '' : ' off'}">
|
|
${itemPreview(it)}
|
|
<div class="sh-card-name" title="${esc(it.name)}">${esc(it.name)}</div>
|
|
<div class="sh-card-row">
|
|
<span class="sh-price">${it.price} <i data-lucide="coins" style="width:12px;height:12px;vertical-align:-2px;color:#d98a17"></i></span>
|
|
<span class="sh-sold">продано: ${it.sold_count || 0}</span>
|
|
</div>
|
|
<div class="sh-card-foot">
|
|
<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>
|
|
<div class="sh-acts">
|
|
<button class="btn-edit-q" onclick="shopAdminEditItem(${it.id})">Ред.</button>
|
|
<button class="btn-del-q" onclick="shopAdminDeleteItem(${it.id})" title="Удалить"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 wrap = 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) { wrap.innerHTML = '<div class="sh-empty">Нет товаров</div>'; return; }
|
|
if (!filtered.length) { wrap.innerHTML = '<div class="sh-empty">Ничего не найдено</div>'; return; }
|
|
|
|
const groups = [];
|
|
const seen = new Set();
|
|
for (const g of GROUP_ORDER) {
|
|
const items = filtered.filter(it => it.type === g.type);
|
|
if (items.length) { groups.push({ label: g.label, items }); seen.add(g.type); }
|
|
}
|
|
const other = filtered.filter(it => !seen.has(it.type));
|
|
if (other.length) groups.push({ label: 'Прочее', items: other });
|
|
|
|
wrap.innerHTML = groups.map(g => `
|
|
<div class="sh-group">
|
|
<div class="sh-group-h">${g.label}<span class="sh-group-c">${g.items.length}</span></div>
|
|
<div class="sh-grid">${g.items.map(cardHTML).join('')}</div>
|
|
</div>`).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,
|
|
};
|
|
})();
|