v2.4: Medienbibliothek+WebP, Varianten-Matrix, Litestream-Backups, intelligentere Analytics

P1 Medien: eigener Admin-Bereich /admin/medien (Grid, Mehrfach-Upload, Drag&Drop, Alt-Text, URL kopieren, Loeschen). Upload konvertiert JPG/PNG via sharp zu WebP (Qualitaet 82, max 2000px), Original wird verworfen; WebP/SVG/GIF/AVIF unveraendert; Konvertierungsfehler -> Original behalten statt 500. media um alt/width/height erweitert. Wiederverwendbarer Medien-Picker (public/media-picker.js) ersetzt den URL-Prompt im Block-Editor, Produkt-Editor (Karte/Galerie/Varianten-Bild), Slides und Popups. JSON-Quelle /api/admin/media (session-gesichert).

P2 Varianten: products.options_json + Tabelle product_variants. Produkt-Editor mit Options-Definition + Matrix-Generator (Preis-Override/Bestand/SKU/Bild/aktiv je Variante). PDP-Selektoren -> Variante; Cart/Checkout tragen sku+Options, Order-Item bekommt sku/variant, Variantenpreis serverseitig verifiziert. Produkte ohne Optionen unveraendert.

P3 Litestream: Binary im Dockerfile, docker-entrypoint.sh (Restore+replicate nur bei LITESTREAM_REPLICA_URL, sonst reiner Node-Start), litestream.yml, Backup-Status unter Einstellungen, README + .env.example.

P4 Analytics: Bestseller, Top-Suchbegriffe, Umsatz/Quelle, Umsatz-Zeitreihe, AOV, Wiederkaufrate, Lager-Warnungen.

Neue Dep sharp. +19 Unit-Tests (49 gesamt gruen), Build + Smoke (P1-P4) gruen.
This commit is contained in:
2026-06-18 08:09:57 +00:00
parent 30c41c355e
commit 50dfca59e1
28 changed files with 1147 additions and 66 deletions
+148
View File
@@ -0,0 +1,148 @@
/* hd-commerce — Wiederverwendbarer Medien-Picker (Admin).
API: window.HDCMedia.pick({ multiple, onPick(url|urls) }) öffnet ein Modal mit
Bibliothek (/api/admin/media), Inline-Upload (WebP) und Auswahl. */
(function () {
var overlay = null, listEl = null, state = { multiple: false, onPick: null, selected: [] };
function el(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
function build() {
overlay = el('div', 'mp-overlay');
overlay.innerHTML =
'<div class="mp-modal" role="dialog" aria-modal="true" aria-label="Medien auswählen">' +
'<div class="mp-head">' +
'<strong>Medien-Bibliothek</strong>' +
'<div class="mp-head-actions">' +
'<label class="mp-upbtn">+ Hochladen<input type="file" accept="image/*" multiple hidden></label>' +
'<button type="button" class="mp-close" aria-label="Schließen">✕</button>' +
'</div>' +
'</div>' +
'<div class="mp-drop" data-drop>Bilder hierher ziehen oder „Hochladen" — JPG/PNG werden automatisch zu WebP.</div>' +
'<div class="mp-msg" data-msg></div>' +
'<div class="mp-grid" data-grid><div class="mp-empty">Lädt …</div></div>' +
'<div class="mp-foot">' +
'<span class="mp-sel" data-sel></span>' +
'<div style="flex:1"></div>' +
'<button type="button" class="mp-btn mp-cancel">Abbrechen</button>' +
'<button type="button" class="mp-btn mp-primary mp-confirm">Auswählen</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
listEl = overlay.querySelector('[data-grid]');
overlay.querySelector('.mp-close').addEventListener('click', close);
overlay.querySelector('.mp-cancel').addEventListener('click', close);
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
overlay.querySelector('.mp-confirm').addEventListener('click', confirmSel);
var fileInput = overlay.querySelector('.mp-upbtn input');
fileInput.addEventListener('change', function () { upload(fileInput.files); fileInput.value = ''; });
var drop = overlay.querySelector('[data-drop]');
['dragover', 'dragenter'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('over'); }); });
['dragleave', 'drop'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('over'); }); });
drop.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files) upload(e.dataTransfer.files); });
document.addEventListener('keydown', function (e) { if (overlay && overlay.classList.contains('open') && e.key === 'Escape') close(); });
}
function msg(text, kind) {
var m = overlay.querySelector('[data-msg]');
m.textContent = text || '';
m.className = 'mp-msg' + (kind ? ' ' + kind : '') + (text ? ' show' : '');
}
function load() {
listEl.innerHTML = '<div class="mp-empty">Lädt …</div>';
fetch('/api/admin/media', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) { renderGrid((d && d.media) || []); })
.catch(function () { listEl.innerHTML = '<div class="mp-empty">Konnte Medien nicht laden.</div>'; });
}
function renderGrid(media) {
if (!media.length) { listEl.innerHTML = '<div class="mp-empty">Noch keine Medien. Lade oben welche hoch.</div>'; return; }
listEl.innerHTML = '';
media.forEach(function (m) {
var card = el('div', 'mp-card');
if (state.selected.indexOf(m.url) > -1) card.classList.add('sel');
card.innerHTML =
'<div class="mp-thumb"><img src="' + m.url + '" alt="" loading="lazy"></div>' +
'<div class="mp-meta"><span class="mp-name" title="' + (m.filename || '') + '">' + (m.filename || '') + '</span>' +
'<span class="mp-size">' + Math.round((m.size || 0) / 1024) + ' KB' + (m.width ? ' · ' + m.width + '×' + m.height : '') + '</span></div>' +
'<div class="mp-card-acts">' +
'<button type="button" class="mp-ico" data-copy="' + m.url + '" title="URL kopieren">⧉</button>' +
'<button type="button" class="mp-ico mp-del" data-del="' + m.id + '" title="Löschen">🗑</button>' +
'</div>';
card.querySelector('.mp-thumb').addEventListener('click', function () { toggle(m.url, card); });
card.querySelector('[data-copy]').addEventListener('click', function (e) {
e.stopPropagation();
try { navigator.clipboard.writeText(location.origin + m.url); msg('URL kopiert.', 'ok'); } catch (x) {}
});
card.querySelector('[data-del]').addEventListener('click', function (e) {
e.stopPropagation();
if (!confirm('Dieses Medium löschen?')) return;
fetch('/api/admin/media?id=' + m.id, { method: 'DELETE' }).then(function (r) { return r.json(); })
.then(function (d) { if (d.ok) { state.selected = state.selected.filter(function (u) { return u !== m.url; }); load(); } else msg(d.error || 'Löschen fehlgeschlagen.', 'err'); });
});
listEl.appendChild(card);
});
updateSelCount();
}
function toggle(url, card) {
if (state.multiple) {
var i = state.selected.indexOf(url);
if (i > -1) { state.selected.splice(i, 1); card.classList.remove('sel'); }
else { state.selected.push(url); card.classList.add('sel'); }
} else {
state.selected = [url];
Array.prototype.forEach.call(listEl.querySelectorAll('.mp-card'), function (c) { c.classList.remove('sel'); });
card.classList.add('sel');
}
updateSelCount();
}
function updateSelCount() {
var s = overlay.querySelector('[data-sel]');
s.textContent = state.selected.length ? state.selected.length + ' ausgewählt' : '';
}
function upload(files) {
if (!files || !files.length) return;
var fd = new FormData();
Array.prototype.forEach.call(files, function (f) { fd.append('files', f); });
msg('Lädt ' + files.length + ' Datei(en) hoch …');
fetch('/api/upload', { method: 'POST', body: fd }).then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.results) {
var conv = d.results.filter(function (r) { return r.converted; }).length;
msg('Hochgeladen' + (conv ? ' (' + conv + '× zu WebP konvertiert)' : '') + '.', 'ok');
// Neu hochgeladene direkt vorauswählen
d.results.forEach(function (r) { if (r.ok && r.url && state.selected.indexOf(r.url) < 0) { if (state.multiple) state.selected.push(r.url); else state.selected = [r.url]; } });
load();
} else { msg((d && d.error) || 'Upload fehlgeschlagen.', 'err'); }
})
.catch(function () { msg('Upload fehlgeschlagen.', 'err'); });
}
function confirmSel() {
if (!state.selected.length) { close(); return; }
var cb = state.onPick;
var sel = state.selected.slice();
close();
if (cb) cb(state.multiple ? sel : sel[0]);
}
function open() { if (!overlay) build(); overlay.classList.add('open'); msg(''); load(); }
function close() { if (overlay) overlay.classList.remove('open'); }
window.HDCMedia = {
pick: function (opts) {
opts = opts || {};
state.multiple = !!opts.multiple;
state.onPick = opts.onPick || null;
state.selected = [];
open();
}
};
})();
+8 -1
View File
@@ -19,7 +19,7 @@
}
function add(item) {
var c = read();
var ex = c.find(function (i) { return i.slug === item.slug && i.size === item.size; });
var ex = c.find(function (i) { return i.slug === item.slug && (i.sku || '') === (item.sku || '') && i.size === item.size; });
if (ex) ex.qty += item.qty || 1; else c.push(item);
write(c);
track('add_to_cart', (item.priceCents || 0) * (item.qty || 1), { slug: item.slug });
@@ -44,7 +44,14 @@
updateBadge();
document.querySelectorAll('[data-add-to-cart]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (btn.disabled) return;
var p = JSON.parse(btn.getAttribute('data-product') || '{}');
if (p.hasVariants) {
if (!p.variant || !p.variant.options) { var st = document.getElementById('variantStatus'); if (st) { st.textContent = 'Bitte zuerst eine Variante wählen.'; st.style.color = '#b3261e'; } return; }
var label = Object.keys(p.variant.options).map(function (k) { return p.variant.options[k]; }).join(' / ');
add({ slug: p.slug, name: p.name, size: label, priceCents: p.priceCents, image: p.image, qty: 1, sku: p.variant.sku || '', variant: p.variant.options, options: p.variant.options });
return;
}
var sizeSel = document.querySelector('.size-chip.active');
var size = sizeSel ? sizeSel.getAttribute('data-size') : (p.sizes && p.sizes[0]) || 'One Size';
add({ slug: p.slug, name: p.name, size: size, priceCents: p.priceCents, image: p.image, qty: 1 });