feat(shop): каталог товаров карточками по типам с реальным превью

Таблица заменена на сетку карточек, сгруппированных по типам
(Рамки/Титулы/Фоны/Эффекты) с заголовками и счётчиками. Каждая
карточка показывает настоящий вид товара:
- 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>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 14:24:33 +03:00
parent 0a24a66a2e
commit 0b0c113181
2 changed files with 98 additions and 50 deletions
+75 -30
View File
@@ -7,10 +7,64 @@
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 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, '&quot;');
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 {
@@ -47,7 +101,7 @@
}
function renderShopItems() {
const body = document.getElementById('shop-items-body');
const wrap = document.getElementById('shop-items-body');
const countEl = document.getElementById('shop-count');
const filtered = _shopItems.filter(it =>
(!_filterType || it.type === _filterType) &&
@@ -56,32 +110,23 @@
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 (!_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();
}