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:
+23
-20
@@ -1339,18 +1339,28 @@
|
||||
.shop-stat-v { font-family:'Unbounded',sans-serif; font-size:1.2rem; font-weight:800; }
|
||||
.shop-stat-v.sm { font-size:0.92rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.shop-stat-l { font-size:0.73rem; color:var(--text-3); font-weight:600; }
|
||||
.shop-cell { display:flex; align-items:center; gap:11px; }
|
||||
.shop-ic { width:34px; height:34px; border-radius:10px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
|
||||
.shop-ic svg { width:17px; height:17px; }
|
||||
.shop-cell-txt { display:flex; flex-direction:column; line-height:1.25; min-width:0; }
|
||||
.shop-cell-sub { font-size:0.76rem; color:var(--text-3); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:300px; }
|
||||
.shop-type-badge { display:inline-block; padding:3px 11px; border-radius:var(--r-pill); font-size:0.76rem; font-weight:700; white-space:nowrap; }
|
||||
.shop-price { white-space:nowrap; font-weight:600; }
|
||||
.shop-ic.t-frame, .shop-type-badge.t-frame { background:rgba(155,93,229,.12); color:var(--violet); }
|
||||
.shop-ic.t-title, .shop-type-badge.t-title { background:rgba(255,179,71,.16); color:#d98a17; }
|
||||
.shop-ic.t-background, .shop-type-badge.t-background { background:rgba(6,214,224,.12); color:#0891a3; }
|
||||
.shop-ic.t-effect, .shop-type-badge.t-effect { background:rgba(6,214,160,.13); color:#089f7a; }
|
||||
.shop-ic.t-other, .shop-type-badge.t-other { background:rgba(120,130,150,.14); color:var(--text-3); }
|
||||
|
||||
.sh-group { margin-bottom:26px; }
|
||||
.sh-group-h { display:flex; align-items:center; gap:9px; margin:0 0 13px; font-family:'Unbounded',sans-serif; font-size:0.82rem; font-weight:700; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-2); }
|
||||
.sh-group-c { font-family:'Manrope',sans-serif; font-size:0.72rem; font-weight:700; color:var(--text-3); background:var(--border-h); padding:1px 9px; border-radius:var(--r-pill); letter-spacing:0; }
|
||||
.sh-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(185px, 1fr)); gap:14px; }
|
||||
.sh-card { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:10px; display:flex; flex-direction:column; gap:9px; transition:border-color var(--tr), box-shadow var(--tr); }
|
||||
.sh-card:hover { border-color:var(--violet); box-shadow:var(--shadow); }
|
||||
.sh-card.off { opacity:0.5; }
|
||||
.sh-prev { width:100%; aspect-ratio:16/10; border-radius:10px; display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; background:#f5f7fb; }
|
||||
.sh-av { width:46px; height:46px; border-radius:50%; box-sizing:border-box; background:linear-gradient(135deg, rgba(155,93,229,0.55), rgba(6,214,224,0.42)); }
|
||||
.sh-prev-title { font-family:'Unbounded',sans-serif; font-weight:800; font-size:0.92rem; letter-spacing:0.5px; text-transform:uppercase; padding:0 8px; text-align:center; line-height:1.15; }
|
||||
.sh-fx-ic { color:var(--text-3); }
|
||||
.sh-fx-ic svg { width:20px; height:20px; }
|
||||
@keyframes sh-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(155,93,229,0.5)} 50%{box-shadow:0 0 0 8px rgba(155,93,229,0)} }
|
||||
.sh-av.fx-pulse { animation:sh-pulse 2s infinite; }
|
||||
.sh-card-name { font-weight:700; font-size:0.9rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.sh-card-row { display:flex; align-items:baseline; justify-content:space-between; gap:8px; }
|
||||
.sh-price { white-space:nowrap; font-weight:700; font-size:0.9rem; }
|
||||
.sh-sold { font-size:0.74rem; color:var(--text-3); white-space:nowrap; }
|
||||
.sh-card-foot { display:flex; align-items:center; justify-content:space-between; gap:8px; border-top:1px solid var(--border); padding-top:9px; }
|
||||
.sh-acts { display:flex; gap:4px; }
|
||||
.sh-empty { color:var(--text-3); padding:30px; text-align:center; }
|
||||
</style>
|
||||
<div class="section-title">Магазин</div>
|
||||
<div class="shop-stats" id="shop-stats-grid"><div class="spinner"></div></div>
|
||||
@@ -1367,14 +1377,7 @@
|
||||
<span class="t-count" id="shop-count"></span>
|
||||
<button class="btn-add" onclick="shopAdminCreateItem()">+ Добавить товар</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Товар</th><th>Тип</th><th>Цена</th><th>Продано</th><th>Активен</th><th>Действия</th>
|
||||
</tr></thead>
|
||||
<tbody id="shop-items-body"><tr><td colspan="6"><div class="spinner"></div></td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="sh-list" id="shop-items-body"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Геймификация ── -->
|
||||
|
||||
@@ -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, '"');
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user