diff --git a/.env.example b/.env.example index 3d6fa10..b08a274 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,20 @@ SMTP_SECURE=false CRON_TOKEN= # Karten, die älter als X Minuten sind und weder bezahlt noch erinnert wurden, werden erinnert. ABANDONED_AFTER_MINUTES=30 + +# --- Medien / WebP (v2.4) --- +# Uploads von JPG/PNG werden automatisch zu WebP konvertiert (Dependency: sharp, prebuilt-Binary). +# Im node:22-slim-Image baubar (python3/make/g++ sind im Dockerfile vorhanden). +WEBP_QUALITY=82 +WEBP_MAX_WIDTH=2000 + +# --- Backup: Litestream (v2.4, optional) --- +# Ohne diese Variablen läuft die App normal OHNE Backup (reiner Node-Start). +# Ist LITESTREAM_REPLICA_URL gesetzt, wird die SQLite-DB (DB_PATH) live nach S3/B2 gestreamt +# und beim Start bei Bedarf wiederhergestellt. +# Backblaze B2 ist S3-kompatibel: s3:/// +LITESTREAM_REPLICA_URL= +LITESTREAM_ACCESS_KEY_ID= +LITESTREAM_SECRET_ACCESS_KEY= +# B2-S3-Endpoint, z. B. s3.eu-central-003.backblazeb2.com (für AWS S3 leer lassen) +LITESTREAM_ENDPOINT= diff --git a/Dockerfile b/Dockerfile index b5ee3ac..9f5a8ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,24 @@ FROM node:22-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates wget && rm -rf /var/lib/apt/lists/* + +# --- Litestream (SQLite-Streaming-Backup nach S3/B2) --- +ARG LITESTREAM_VERSION=0.3.13 +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in amd64) ls_arch=amd64 ;; arm64) ls_arch=arm64 ;; *) ls_arch=amd64 ;; esac; \ + wget -qO /tmp/litestream.tar.gz "https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-${ls_arch}.tar.gz"; \ + tar -C /usr/local/bin -xzf /tmp/litestream.tar.gz; \ + rm /tmp/litestream.tar.gz; \ + litestream version + COPY package*.json ./ RUN npm install --no-audit --no-fund COPY . . RUN npm run build -ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production DB_PATH=/data/hdc.db +RUN chmod +x ./docker-entrypoint.sh +ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production DB_PATH=/data/hdc.db LITESTREAM_CONFIG=/app/litestream.yml RUN mkdir -p /data EXPOSE 4321 HEALTHCHECK --interval=15s --timeout=5s --start-period=25s --retries=5 CMD wget -qO- http://127.0.0.1:4321/ >/dev/null 2>&1 || exit 1 -CMD ["node","./dist/server/entry.mjs"] +CMD ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 6fedc28..6e67ceb 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ Die mitgelieferte Demo-Instanz heißt **„Brittas Nähkiste"** (Kurzwaren/Nähb - **Kundenkonten + Adressbuch** (`feature_accounts`): eigene Kunden-Session (Cookie `hdc_customer`, getrennt vom Admin; scrypt-Hash). `/konto/registrieren`, `/konto/anmelden`, `/konto` (Bestellhistorie + Adressbuch), `/konto/abmelden`. Tabelle `customer_addresses`; Checkout füllt die Adresse vor und ordnet die Bestellung dem Konto zu (`orders.customer_id`). Gast-Checkout bleibt möglich. - **Bewertungen** (`feature_reviews`): Tabelle `reviews` (Sterne 1–5, Moderation `approved`). Formular auf der Produktseite (`/api/review`, speichert `approved=0`), Anzeige von Durchschnitt + freigegebenen Reviews, optionale `aggregateRating` im Produkt-JSON-LD. Admin-Bereich **Bewertungen** (Owner/Redaktion): Freigeben/Verbergen/Löschen, Zähler offener Reviews in der Nav. - **Warenkorb-Erinnerung** (`feature_abandoned_cart`): beim Checkout-Start wird der Warenkorb serverseitig in `abandoned_carts` gesichert (`/api/cart-capture`). Versand-Trigger: **`POST /api/cron/abandoned`** (Header `Authorization: Bearer ` oder `?token=`), schickt für fällige, nicht erinnerte Karten eine gebrandete Erinnerungsmail (Mailer/Log-Fallback) und setzt `reminded=1`. Erfolgreiche Bestellung der Adresse setzt `recovered=1`. Status/Zähler unter Einstellungen. Als **Coolify-Scheduled-Task** z. B. alle 30 Min `curl -fsS -X POST -H "Authorization: Bearer $CRON_TOKEN" https://shop.example.com/api/cron/abandoned` aufrufen. +- **Medienbibliothek + WebP (v2.4):** eigener Admin-Bereich **Medien** (`/admin/medien`) mit Grid (Thumbnail, Dateiname, Größe/Maße, Alt-Text, „URL kopieren", Löschen) und **Mehrfach-Upload** (Drag&Drop). Beim Upload werden **JPG/JPEG/PNG automatisch zu WebP konvertiert** (sharp, Qualität ~82, max-Breite ~2000px); das Original wird verworfen, nur die `.webp` bleibt. WebP/SVG/GIF/AVIF werden unverändert durchgereicht; bei Konvertierungsfehler bleibt das Original erhalten (kein Absturz). Ein **wiederverwendbarer Medien-Picker** (`public/media-picker.js`) ersetzt überall den alten URL-Prompt: Block-Editor (Hero/Bild/Galerie-Mehrfach), Produkt-Editor (Karten-Bild, Galerie, Varianten-Bild), Slides & Popups. `media` um `alt`/`width`/`height` erweitert; JSON-Quelle `/api/admin/media` (session-gesichert). +- **Varianten-Matrix (v2.4):** Produkte definieren Optionen (`options` = `[{name, values[]}]`, z. B. Größe × Farbe). Der Produkt-Editor erzeugt daraus die **Varianten-Matrix** (`product_variants`: `sku`, `options_json`, `price_cents`-Override, `stock`, `image`, `active`). Storefront-PDP zeigt Options-Selektoren → wählt Variante → Preis/Bestand/Bild aktualisieren, „nicht lieferbar" bei inaktiv/ausverkauft. Warenkorb & Checkout tragen `sku` + Options; das Order-Item bekommt `sku`/`variant`, der Variantenpreis wird **serverseitig** verifiziert. Produkte ohne Optionen verhalten sich wie bisher (einfache „Größen"-Liste bleibt). +- **Intelligentere Analytics (v2.4):** Conversion je Produkt (Ansichten→Käufe), **Bestseller** (Menge/Umsatz), **Umsatz pro Quelle/UTM**, **Top-Suchbegriffe** (`search`-Events von `/suche`, inkl. Null-Treffer-Markierung), **Umsatz-Zeitreihe** (Chart.js, 30 Tage), **AOV**, **Wiederkaufrate** und **Lager-Warnungen** (knappe Produkte & Varianten) — alles aus SQLite aggregiert, kein externer Dienst. +- **Litestream-Backups (v2.4):** optionales Streaming-Backup der SQLite-DB nach S3/Backblaze B2. Ist `LITESTREAM_REPLICA_URL` gesetzt, stellt der Container beim Start die DB bei Bedarf wieder her (`litestream restore`) und repliziert dann live (`litestream replicate -exec`); ohne Replica startet die App normal ohne Backup. Status sichtbar unter **Admin → Einstellungen → Backup**. Siehe Abschnitt „Backups". - **Editierbare, gebrandete 404** (v2.1): `src/pages/404.astro` rendert die System-Seite mit Slug `404` über den Block-Builder. Wird per `ensureSystemPages()` bei jedem Boot idempotent angelegt und ist im Admin unter **Inhalte** editierbar. - **Engine**: synchron via `better-sqlite3` (WAL), automatisches Seeding beim ersten Start. - **First-Party-Analytics**: eigene `events`-Tabelle, kein externer Dienst (Session = täglich rollender Hash). @@ -110,9 +114,31 @@ docker run -p 4321:4321 -v hdc-data:/data \ Das `Dockerfile` (node:22-slim) baut `better-sqlite3` nativ, legt `/data` an und setzt `DB_PATH=/data/hdc.db`. Auf Coolify ein persistentes Volume auf `/data` mounten. HEALTHCHECK prüft `/`. +Das Image enthält zusätzlich das **Litestream**-Binary und startet über `docker-entrypoint.sh`: ohne Backup-ENV ein reiner `node ./dist/server/entry.mjs`, mit `LITESTREAM_REPLICA_URL` ein `litestream replicate -exec` (nach optionalem Restore). + +## Backups (Litestream → Backblaze B2 / S3) + +Optionales kontinuierliches Streaming-Backup der SQLite-DB. Ohne Konfiguration läuft die App unverändert ohne Backup. + +1. **B2-Bucket anlegen** (Backblaze) und einen Application-Key mit Schreibrechten erzeugen. B2 ist S3-kompatibel. +2. **ENV setzen** (Coolify → App → Environment): + ``` + LITESTREAM_REPLICA_URL=s3:/// + LITESTREAM_ACCESS_KEY_ID= + LITESTREAM_SECRET_ACCESS_KEY= + LITESTREAM_ENDPOINT=s3.eu-central-003.backblazeb2.com # B2-S3-Endpoint; für AWS S3 leer lassen + ``` + (Für AWS S3 genügen `LITESTREAM_REPLICA_URL` + die beiden Keys; `LITESTREAM_ENDPOINT` bleibt leer.) +3. **Restore** (z. B. neue Instanz / Disaster-Recovery) — die DB wird beim Start automatisch wiederhergestellt, falls lokal keine existiert. Manuell: + ``` + litestream restore -config /app/litestream.yml -if-replica-exists /data/hdc.db + ``` + +Der Status (konfiguriert? Ziel?) ist unter **Admin → Einstellungen → Backup (Litestream)** sichtbar. Hinweis: Die Live-Demo nutzt `DB_PATH=/data/hdc2.db`. + ## Datenmodell -`settings` (inkl. Feature-Flags & `payment_provider`), `products` (inkl. `mwst` / `base_amount` / `base_unit` / `base_price_per`), `orders` (inkl. `discount_code`/`discount_cents`, `tax_cents`/`shipping_cents`/`country`, `payment_provider`/`payment_id`), `customers`, `slides`, `pages` (inkl. `blocks`; System-Seite `404`), `popups` (inkl. `style` / `discount_id`), `discounts`, `discount_redemptions`, `shipping_zones`, `email_log`, `subscribers`, `events`, `media`, `users`, `audit` — alles seed-bar und im Admin pflegbar. +`settings` (inkl. Feature-Flags & `payment_provider`), `products` (inkl. `mwst` / `base_amount` / `base_unit` / `base_price_per`), `orders` (inkl. `discount_code`/`discount_cents`, `tax_cents`/`shipping_cents`/`country`, `payment_provider`/`payment_id`), `customers`, `slides`, `pages` (inkl. `blocks`; System-Seite `404`), `popups` (inkl. `style` / `discount_id`), `discounts`, `discount_redemptions`, `shipping_zones`, `email_log`, `subscribers`, `events`, `media` (inkl. `alt`/`width`/`height`), `product_variants` (Größe×Farbe-Matrix), `users`, `audit` — alles seed-bar und im Admin pflegbar. --- diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..910dad8 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -e + +: "${DB_PATH:=/data/hdc.db}" +export DB_PATH + +APP_CMD="node ./dist/server/entry.mjs" + +# Litestream nur nutzen, wenn ein Replica-Ziel konfiguriert ist. +if [ -n "$LITESTREAM_REPLICA_URL" ] && command -v litestream >/dev/null 2>&1; then + echo "[entrypoint] Litestream aktiv — Replica: $LITESTREAM_REPLICA_URL" + # DB aus dem Backup wiederherstellen, falls lokal noch keine existiert. + if [ ! -f "$DB_PATH" ]; then + echo "[entrypoint] Keine lokale DB unter $DB_PATH — versuche Restore aus Replica …" + : "${LITESTREAM_CONFIG:=/app/litestream.yml}" + litestream restore -config "$LITESTREAM_CONFIG" -if-replica-exists "$DB_PATH" || echo "[entrypoint] Kein Replica gefunden, starte mit frischer DB." + fi + exec litestream replicate -config "$LITESTREAM_CONFIG" -exec "$APP_CMD" +else + if [ -n "$LITESTREAM_REPLICA_URL" ]; then + echo "[entrypoint] LITESTREAM_REPLICA_URL gesetzt, aber litestream-Binary fehlt — starte ohne Backup." + else + echo "[entrypoint] Kein Litestream-Replica konfiguriert — normaler Start ohne Backup." + fi + exec $APP_CMD +fi diff --git a/litestream.yml b/litestream.yml new file mode 100644 index 0000000..184ee39 --- /dev/null +++ b/litestream.yml @@ -0,0 +1,10 @@ +# hd-commerce — Litestream-Konfiguration (optional). +# Wird nur aktiv, wenn eine Replica konfiguriert ist (LITESTREAM_REPLICA_URL bzw. B2/S3-ENV). +# Backblaze B2 ist S3-kompatibel: LITESTREAM_REPLICA_URL=s3:/// +dbs: + - path: ${DB_PATH} + replicas: + - url: ${LITESTREAM_REPLICA_URL} + access-key-id: ${LITESTREAM_ACCESS_KEY_ID} + secret-access-key: ${LITESTREAM_SECRET_ACCESS_KEY} + endpoint: ${LITESTREAM_ENDPOINT} diff --git a/package-lock.json b/package-lock.json index 73f450c..0654473 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hd-commerce", - "version": "2.2.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hd-commerce", - "version": "2.2.0", + "version": "2.4.0", "dependencies": { "@astrojs/node": "^9.1.3", "@fontsource-variable/fraunces": "^5.1.0", @@ -14,6 +14,7 @@ "astro": "^5.6.0", "better-sqlite3": "^11.8.1", "nodemailer": "^6.10.1", + "sharp": "^0.34.5", "stripe": "^17.5.0" } }, @@ -609,7 +610,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -4904,7 +4904,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", diff --git a/package.json b/package.json index 55d6692..e20a822 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hd-commerce", "type": "module", - "version": "2.3.0", + "version": "2.4.0", "private": true, "description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)", "scripts": { @@ -18,6 +18,7 @@ "astro": "^5.6.0", "better-sqlite3": "^11.8.1", "nodemailer": "^6.10.1", + "sharp": "^0.34.5", "stripe": "^17.5.0" } -} \ No newline at end of file +} diff --git a/public/media-picker.js b/public/media-picker.js new file mode 100644 index 0000000..8f3ecf6 --- /dev/null +++ b/public/media-picker.js @@ -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 = + ''; + 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 = '
Lädt …
'; + 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 = '
Konnte Medien nicht laden.
'; }); + } + + function renderGrid(media) { + if (!media.length) { listEl.innerHTML = '
Noch keine Medien. Lade oben welche hoch.
'; 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 = + '
' + + '
' + (m.filename || '') + '' + + '' + Math.round((m.size || 0) / 1024) + ' KB' + (m.width ? ' · ' + m.width + '×' + m.height : '') + '
' + + '
' + + '' + + '' + + '
'; + 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(); + } + }; +})(); diff --git a/public/shop.js b/public/shop.js index 723abe4..4011e85 100644 --- a/public/shop.js +++ b/public/shop.js @@ -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 }); diff --git a/src/layouts/Admin.astro b/src/layouts/Admin.astro index 4094828..50b0ca9 100644 --- a/src/layouts/Admin.astro +++ b/src/layouts/Admin.astro @@ -30,6 +30,7 @@ const allNav = [ { key:'marketing', label:'Marketing', href: base + '/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' }, { key:'rabatte', label:'Rabatte', href: base + '/rabatte', icon:'M9 9h.01M15 15h.01M8 21l13-13a2.83 2.83 0 0 0 0-4 2.83 2.83 0 0 0-4 0L4 17a2 2 0 0 0 0 3 2 2 0 0 0 4 1Z' }, { key:'inhalte', label:'Inhalte', href: base + '/inhalte', icon:'M4 4h16v4H4V4Zm0 6h10v10H4V10Zm12 0h4v10h-4V10Z' }, + { key:'medien', label:'Medien', href: base + '/medien', icon:'M3 5h18v14H3z M3 16l5-5 4 4 3-3 6 6 M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z' }, { key:'versandzonen', label:'Versand', href: base + '/versand', icon:'M3 7h11v8H3V7Zm11 3h4l3 3v2h-7v-5ZM7 19a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm10 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z' }, ]; const reviewsOn = feature('feature_reviews'); diff --git a/src/lib/admin-api.js b/src/lib/admin-api.js index 1545550..9298062 100644 --- a/src/lib/admin-api.js +++ b/src/lib/admin-api.js @@ -19,7 +19,9 @@ export function authOk(request) { // ---- Ressourcen-Definitionen für das Manifest ---- export const RESOURCES = { - products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'mwst(0|7|19)', 'base_amount', 'base_unit', 'base_price_per', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}'] }, + products: { rw: true, fields: ['slug', 'name', 'shortName', 'priceCents', 'mwst(0|7|19)', 'base_amount', 'base_unit', 'base_price_per', 'category', 'sizes[]', 'images[]', 'cardImage', 'badge', 'stock', 'material', 'features[]', 'featured', 'sort', 'desc', 'metafields{}', 'options[]{name,values[]}'] }, + product_variants: { rw: true, fields: ['product_id', 'options{}', 'sku', 'price_cents(override, nullable)', 'stock', 'image', 'sort', 'active(0|1)'] }, + media: { rw: false, fields: ['filename', 'url', 'mime', 'size', 'alt', 'width', 'height', 'created_at'] }, pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] }, slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] }, popups: { rw: true, fields: ['title', 'type(newsletter|discount|announcement|exit)', 'headline', 'body', 'image', 'cta_text', 'cta_url', 'trigger', 'trigger_value', 'target_path', 'freq', 'active', 'sort', 'style(modal|slidein|bar)', 'discount_id'] }, @@ -45,12 +47,14 @@ export function listResource(name) { case 'settings': return store.getSettings(); case 'reviews': return store.listReviews(); case 'abandoned_carts': return store.listAbandonedCarts(); + case 'media': return store.listMedia(); + case 'product_variants': return store.listAllVariants(); default: return null; } } export function getResource(name, id) { switch (name) { - case 'products': return store.getProductById(id); + case 'products': { const pr = store.getProductById(id); return pr ? { ...pr, variants: store.listVariants(pr.id) } : null; } case 'pages': return /^\d+$/.test(String(id)) ? store.getPageById(id) : store.getPageBySlug(id); case 'slides': return store.getSlideById(id); case 'popups': return store.getPopupById(id); @@ -59,6 +63,8 @@ export function getResource(name, id) { case 'orders': return store.getOrderById(id); case 'customers': return store.getCustomerById(id); case 'reviews': return store.getReviewById(id); + case 'media': return store.getMediaById(id); + case 'product_variants': return store.listVariants(id); default: return null; } } @@ -66,9 +72,10 @@ export function getResource(name, id) { // upsert: bei id -> update, sonst create. Für products/pages erlaubt auch slug als Schlüssel. export function upsertResource(name, body) { if (name === 'products') { - if (body.id) { store.updateProduct(body.id, body); return { id: Number(body.id), ...store.getProductById(body.id) }; } - if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); return store.getProductById(ex.id); } } - const id = store.createProduct(body); return store.getProductById(id); + const applyVariants = (pid) => { if (Array.isArray(body.variants)) { try { store.setProductVariants(pid, body.variants); } catch {} } }; + if (body.id) { store.updateProduct(body.id, body); applyVariants(body.id); const pr = store.getProductById(body.id); return { id: Number(body.id), ...pr, variants: store.listVariants(body.id) }; } + if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); applyVariants(ex.id); return { ...store.getProductById(ex.id), variants: store.listVariants(ex.id) }; } } + const id = store.createProduct(body); applyVariants(id); return { ...store.getProductById(id), variants: store.listVariants(id) }; } if (name === 'pages') { if (body.id) { store.updatePage(body.id, body); return store.getPageById(body.id); } @@ -99,6 +106,13 @@ export function upsertResource(name, body) { if (body.approved) store.setReviewApproved(res.id, 1); return store.getReviewById(res.id); } + if (name === 'product_variants') { + const pid = body.product_id || body.productId; + if (!pid) throw new Error('product_variants benötigt product_id'); + const list = Array.isArray(body.variants) ? body.variants : (Array.isArray(body) ? body : []); + store.setProductVariants(pid, list); + return store.listVariants(pid); + } if (name === 'shipping_zones') { if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); } const id = store.createShippingZone(body); return store.getShippingZoneById(id); @@ -142,7 +156,7 @@ export function manifest(origin) { ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' }); return { name: 'hd-commerce Admin API', - version: '2.3.0', + version: '2.4.0', auth: 'Authorization: Bearer ', base_url: origin || '', resources: RESOURCES, @@ -160,6 +174,8 @@ export function manifest(origin) { 'Feature-Flags & payment_provider sind Settings-Keys (über /api/admin/settings setzbar): feature_newsletter, feature_accounts, feature_reviews, feature_wishlist, feature_abandoned_cart, feature_search, payment_provider (mollie|stripe|demo).', 'reviews: POST ohne id legt eine Bewertung an (approved=0); POST mit { id, approved:true|false } moderiert. rating 1–5.', 'abandoned_carts ist nur lesbar; Versand-Trigger ist POST /api/cron/abandoned (Bearer CRON_TOKEN).', + 'products.options ist [{name, values[]}]. Varianten als product_variants pflegen: POST /api/admin/product_variants { product_id, variants:[{options:{"Größe":"M","Farbe":"Blau"}, sku, price_cents(nullable=Basispreis), stock, image, active}] } ersetzt die Matrix. Alternativ products-POST mit variants:[...].', + 'media ist nur lesbar (Liste). Upload über POST /api/upload (multipart, Felder file/files) — JPG/PNG werden zu WebP konvertiert.', ], }; } diff --git a/src/lib/auth.js b/src/lib/auth.js index 42ceacb..8704d8a 100644 --- a/src/lib/auth.js +++ b/src/lib/auth.js @@ -88,8 +88,8 @@ export function currentUser(request) { // --- Rollen-Gate --- // owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen const ROLE_SECTIONS = { - owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'], - redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'], + owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'medien', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'], + redaktion: ['dashboard', 'produkte', 'inhalte', 'medien', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'], versand: ['bestellungen'], }; export function canAccess(role, section) { diff --git a/src/lib/store-sqlite.js b/src/lib/store-sqlite.js index ca4bc95..ecfff3e 100644 --- a/src/lib/store-sqlite.js +++ b/src/lib/store-sqlite.js @@ -118,8 +118,31 @@ CREATE TABLE IF NOT EXISTS email_log ( ); `); +// v2.4 — Medien: Alt-Text + Maße +ensureColumn('media', 'alt', "alt TEXT DEFAULT ''"); +ensureColumn('media', 'width', "width INTEGER"); +ensureColumn('media', 'height', "height INTEGER"); + +// v2.4 — Varianten-Matrix: definierte Optionen am Produkt + Varianten-Tabelle +ensureColumn('products', 'options_json', "options_json TEXT DEFAULT '[]'"); +db.exec(` +CREATE TABLE IF NOT EXISTS product_variants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + product_id INTEGER NOT NULL, + sku TEXT DEFAULT '', + options_json TEXT DEFAULT '{}', + price_cents INTEGER, + stock INTEGER, + image TEXT DEFAULT '', + sort INTEGER DEFAULT 0, + active INTEGER DEFAULT 1, + created_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_variants_product ON product_variants(product_id); +`); + // ---------- mappers ---------- -const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), featured: !!r.featured, mwst: (r.mwst == null ? 19 : Number(r.mwst)) }); +const P = (r) => r && ({ ...r, sizes: JSON.parse(r.sizes || '[]'), images: JSON.parse(r.images || '[]'), features: JSON.parse(r.features || '[]'), metafields: JSON.parse(r.metafields || '{}'), options: (() => { try { return JSON.parse(r.options_json || '[]'); } catch { return []; } })(), featured: !!r.featured, mwst: (r.mwst == null ? 19 : Number(r.mwst)) }); const O = (r) => r && ({ ...r, items: JSON.parse(r.items || '[]') }); const E = (r) => r && ({ ...r, meta: JSON.parse(r.meta || '{}') }); @@ -373,19 +396,20 @@ function normProduct(d) { base_amount: (d.base_amount === '' || d.base_amount == null) ? null : Number(d.base_amount), base_unit: d.base_unit || '', base_price_per: d.base_price_per || '', + options_json: JSON.stringify(Array.isArray(d.options) ? d.options : (() => { try { return JSON.parse(d.options_json || '[]'); } catch { return []; } })()), }; } export function createProduct(d) { - const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields,mwst,base_amount,base_unit,base_price_per) - VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields,@mwst,@base_amount,@base_unit,@base_price_per)`).run(normProduct(d)); + const r = db.prepare(`INSERT INTO products (slug,name,shortName,priceCents,category,sizes,images,cardImage,badge,stock,material,features,featured,sort,desc,metafields,mwst,base_amount,base_unit,base_price_per,options_json) + VALUES (@slug,@name,@shortName,@priceCents,@category,@sizes,@images,@cardImage,@badge,@stock,@material,@features,@featured,@sort,@desc,@metafields,@mwst,@base_amount,@base_unit,@base_price_per,@options_json)`).run(normProduct(d)); return r.lastInsertRowid; } export function updateProduct(id, d) { - db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields,mwst=@mwst,base_amount=@base_amount,base_unit=@base_unit,base_price_per=@base_price_per WHERE id=@id`) + db.prepare(`UPDATE products SET slug=@slug,name=@name,shortName=@shortName,priceCents=@priceCents,category=@category,sizes=@sizes,images=@images,cardImage=@cardImage,badge=@badge,stock=@stock,material=@material,features=@features,featured=@featured,sort=@sort,desc=@desc,metafields=@metafields,mwst=@mwst,base_amount=@base_amount,base_unit=@base_unit,base_price_per=@base_price_per,options_json=@options_json WHERE id=@id`) .run({ ...normProduct(d), id: Number(id) }); return id; } -export const deleteProduct = (id) => db.prepare('DELETE FROM products WHERE id=?').run(Number(id)); +export const deleteProduct = (id) => { db.prepare('DELETE FROM product_variants WHERE product_id=?').run(Number(id)); return db.prepare('DELETE FROM products WHERE id=?').run(Number(id)); }; // ---------- orders ---------- export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O); @@ -523,12 +547,108 @@ export function addSubscriber(email, source = 'web') { } export const listSubscribers = () => db.prepare('SELECT * FROM subscribers ORDER BY id DESC').all(); +// ---------- backup status (Litestream) ---------- +export function backupStatus() { + const url = (process.env.LITESTREAM_REPLICA_URL || '').trim(); + const configured = !!url; + const keyId = (process.env.LITESTREAM_ACCESS_KEY_ID || '').trim(); + const secret = (process.env.LITESTREAM_SECRET_ACCESS_KEY || '').trim(); + const endpoint = (process.env.LITESTREAM_ENDPOINT || '').trim(); + let target = ''; + if (url) { try { target = url.replace(/^(s3:\/\/[^/]+).*/, '$1'); } catch { target = url; } } + return { + configured, + target: target || url, + fullCredentials: configured && !!keyId && !!secret, + endpoint, + dbPath: process.env.DB_PATH || './data/hdc.db', + }; +} + // ---------- media ---------- export function addMedia(d) { - return db.prepare('INSERT INTO media (filename,url,mime,size,created_at) VALUES (?,?,?,?,?)') - .run(d.filename, d.url, d.mime || '', d.size || 0, new Date().toISOString()).lastInsertRowid; + return db.prepare('INSERT INTO media (filename,url,mime,size,alt,width,height,created_at) VALUES (?,?,?,?,?,?,?,?)') + .run(d.filename, d.url, d.mime || '', d.size || 0, d.alt || '', (d.width == null ? null : Number(d.width)), (d.height == null ? null : Number(d.height)), new Date().toISOString()).lastInsertRowid; } export const listMedia = () => db.prepare('SELECT * FROM media ORDER BY id DESC').all(); +export const getMediaById = (id) => db.prepare('SELECT * FROM media WHERE id=?').get(Number(id)); +export const getMediaByFilename = (fn) => db.prepare('SELECT * FROM media WHERE filename=?').get(String(fn || '')); +export function updateMediaAlt(id, alt) { + db.prepare('UPDATE media SET alt=? WHERE id=?').run(String(alt || ''), Number(id)); + return getMediaById(id); +} +export function deleteMedia(id) { + const m = getMediaById(id); + db.prepare('DELETE FROM media WHERE id=?').run(Number(id)); + return m; +} + +// ---------- product variants (v2.4) ---------- +const V = (r) => r && ({ ...r, options: (() => { try { return JSON.parse(r.options_json || '{}'); } catch { return {}; } })(), active: !!r.active }); +export const listVariants = (productId) => db.prepare('SELECT * FROM product_variants WHERE product_id=? ORDER BY sort, id').all(Number(productId)).map(V); +export const listAllVariants = () => db.prepare('SELECT * FROM product_variants ORDER BY product_id, sort, id').all().map(V); +export const getVariantById = (id) => V(db.prepare('SELECT * FROM product_variants WHERE id=?').get(Number(id))); +export const getVariantBySku = (sku) => V(db.prepare('SELECT * FROM product_variants WHERE sku=? AND sku<>\'\'').get(String(sku || ''))); +export function deleteVariantsForProduct(productId) { + db.prepare('DELETE FROM product_variants WHERE product_id=?').run(Number(productId)); +} +function normVariant(productId, v, i) { + return { + product_id: Number(productId), + sku: String(v.sku || '').trim(), + options_json: JSON.stringify(v.options && typeof v.options === 'object' ? v.options : {}), + price_cents: (v.price_cents === '' || v.price_cents == null) ? null : Math.round(Number(v.price_cents)), + stock: (v.stock === '' || v.stock == null) ? null : Math.round(Number(v.stock)), + image: String(v.image || ''), + sort: Number.isFinite(Number(v.sort)) ? Math.round(Number(v.sort)) : (i || 0), + active: (v.active === false || v.active === 0 || v.active === '0') ? 0 : 1, + }; +} +// Ersetzt alle Varianten eines Produkts atomar durch die übergebene Liste. +export function setProductVariants(productId, variants) { + const now = new Date().toISOString(); + const ins = db.prepare(`INSERT INTO product_variants (product_id,sku,options_json,price_cents,stock,image,sort,active,created_at) + VALUES (@product_id,@sku,@options_json,@price_cents,@stock,@image,@sort,@active,@created_at)`); + const tx = db.transaction((rows) => { + db.prepare('DELETE FROM product_variants WHERE product_id=?').run(Number(productId)); + rows.forEach((v, i) => ins.run({ ...normVariant(productId, v, i), created_at: now })); + }); + tx(Array.isArray(variants) ? variants : []); + return listVariants(productId); +} +// Generiert alle Options-Kombinationen aus options=[{name,values:[]}]. +export function variantCombinations(options) { + const opts = (Array.isArray(options) ? options : []) + .map(o => ({ name: String(o && o.name || '').trim(), values: (Array.isArray(o && o.values) ? o.values : []).map(x => String(x).trim()).filter(Boolean) })) + .filter(o => o.name && o.values.length); + if (!opts.length) return []; + let combos = [{}]; + for (const o of opts) { + const next = []; + for (const c of combos) for (const val of o.values) next.push({ ...c, [o.name]: val }); + combos = next; + } + return combos; +} +// Setzt das Produkt-options_json und synchronisiert die Varianten-Matrix. +// Bestehende Varianten (per Options-Signatur erkannt) behalten ihre Werte; neue werden angelegt, verschwundene gelöscht. +export function syncProductVariants(productId, options, existingOverrides = []) { + const combos = variantCombinations(options); + const sig = (o) => JSON.stringify(Object.keys(o).sort().reduce((a, k) => (a[k] = o[k], a), {})); + const prev = {}; + for (const v of listVariants(productId)) prev[sig(v.options)] = v; + for (const ov of (existingOverrides || [])) { if (ov && ov.options) prev[sig(ov.options)] = { ...prev[sig(ov.options)], ...ov }; } + const rows = combos.map((opts, i) => { + const ex = prev[sig(opts)] || {}; + return { + options: opts, sku: ex.sku || '', price_cents: ex.price_cents ?? null, + stock: ex.stock ?? null, image: ex.image || '', sort: i, active: ex.active === undefined ? true : ex.active, + }; + }); + setProductVariants(productId, rows); + db.prepare('UPDATE products SET options_json=? WHERE id=?').run(JSON.stringify(Array.isArray(options) ? options : []), Number(productId)); + return listVariants(productId); +} // ---------- events / analytics ---------- export function recordEvent({ type, path = '', referrer = '', utm_source = '', utm_medium = '', utm_campaign = '', session = '', value_cents = 0, meta = {} }) { @@ -577,8 +697,46 @@ export function analyticsSummary(days = 30) { series.push({ date: d0.toISOString().slice(0, 10), label: d0.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }), views, revenue: rev }); } + // v2.4 — Umsatz je Quelle ist bereits in bySource enthalten. + // Top-Suchbegriffe aus search-Events. + const topSearches = db.prepare(`SELECT lower(trim(json_extract(meta,'$.q'))) term, COUNT(*) hits, + SUM(CASE WHEN json_extract(meta,'$.results')=0 THEN 1 ELSE 0 END) zero + FROM events WHERE type='search' AND created_at>=? AND json_extract(meta,'$.q') IS NOT NULL AND trim(json_extract(meta,'$.q'))<>'' + GROUP BY term ORDER BY hits DESC, term LIMIT 12`).all(s); + + // Bestseller: Menge & Umsatz aus den Positionen bezahlter/erfüllter Bestellungen. + const paidOrders = db.prepare("SELECT items, total_cents, email FROM orders WHERE status IN ('paid','fulfilled') AND created_at>=?").all(s); + const sellerMap = {}; + for (const o of paidOrders) { + let items = []; try { items = JSON.parse(o.items || '[]'); } catch {} + for (const it of items) { + const key = it.slug || it.name || '—'; + if (!sellerMap[key]) sellerMap[key] = { key, name: it.name || it.slug || '—', slug: it.slug || '', qty: 0, revenue: 0 }; + const qty = Math.max(1, Number(it.qty) || 1); + sellerMap[key].qty += qty; + sellerMap[key].revenue += (Number(it.priceCents) || 0) * qty; + } + } + const bestsellers = Object.values(sellerMap).sort((a, b) => b.qty - a.qty).slice(0, 10); + + // Wiederkaufrate: Anteil Kund:innen (per E-Mail) mit >1 bezahlter Bestellung (gesamt, nicht zeitgefenstert). + const buyerRows = db.prepare("SELECT email, COUNT(*) c FROM orders WHERE status IN ('paid','fulfilled') AND email<>'' GROUP BY email").all(); + const buyers = buyerRows.length; + const repeatBuyers = buyerRows.filter(r => r.c > 1).length; + const repeatRate = buyers ? (repeatBuyers / buyers) * 100 : 0; + + // Lager-Warnungen: knappe/ausverkaufte Produkte (Bestand gesetzt) + knappe Varianten. + const lowStock = db.prepare('SELECT slug, shortName, name, stock FROM products WHERE stock IS NOT NULL AND stock <= 5 ORDER BY stock ASC LIMIT 12').all() + .map(p => ({ slug: p.slug, name: p.shortName || p.name, stock: p.stock, kind: 'product' })); + const lowVariants = db.prepare(`SELECT v.sku sku, v.stock stock, v.options_json opt, p.shortName sn, p.name nm + FROM product_variants v JOIN products p ON p.id=v.product_id + WHERE v.active=1 AND v.stock IS NOT NULL AND v.stock <= 5 ORDER BY v.stock ASC LIMIT 12`).all() + .map(v => { let o={}; try{o=JSON.parse(v.opt||'{}');}catch{} return { name: (v.sn || v.nm) + ' · ' + Object.values(o).join('/'), sku: v.sku, stock: v.stock, kind: 'variant' }; }); + const stockWarnings = [...lowStock, ...lowVariants].sort((a, b) => a.stock - b.stock).slice(0, 12); + return { days, visitors, pageviews, productViews, addToCart, checkoutStart, purchases, revenue, conversion, aov, + repeatRate, buyers, repeatBuyers, funnel: [ { label: 'Aufrufe', value: pageviews }, { label: 'Produktansichten', value: productViews }, @@ -586,7 +744,7 @@ export function analyticsSummary(days = 30) { { label: 'Checkout', value: checkoutStart }, { label: 'Kauf', value: purchases }, ], - bySource, topProducts, series, + bySource, topProducts, series, topSearches, bestsellers, stockWarnings, }; } diff --git a/src/middleware.js b/src/middleware.js index 79ed52e..b2c244b 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -17,7 +17,7 @@ function sectionOf(adminInner) { const seg = adminInner.replace(/^\//, '').split('/')[0] || 'dashboard'; const map = { '': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden', - 'analytics': 'analytics', 'marketing': 'marketing', 'rabatte': 'rabatte', 'inhalte': 'inhalte', 'einstellungen': 'einstellungen', + 'analytics': 'analytics', 'marketing': 'marketing', 'rabatte': 'rabatte', 'inhalte': 'inhalte', 'medien': 'medien', 'einstellungen': 'einstellungen', 'nutzer': 'nutzer', 'audit': 'audit', 'versand': 'versandzonen', 'bewertungen': 'bewertungen', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout', }; return map[seg] || 'dashboard'; diff --git a/src/pages/admin/analytics/index.astro b/src/pages/admin/analytics/index.astro index 8e6dc5c..9f67d23 100644 --- a/src/pages/admin/analytics/index.astro +++ b/src/pages/admin/analytics/index.astro @@ -9,11 +9,13 @@ const accent = getSetting('brand_accent', '#b8566a'); const maxFunnel = Math.max(...a.funnel.map(f => f.value), 1); const kpis = [ { label: 'Besucher', val: a.visitors.toLocaleString('de-DE') }, - { label: 'Seitenaufrufe', val: a.pageviews.toLocaleString('de-DE') }, + { label: 'Umsatz', val: formatPrice(a.revenue), sub: `${a.purchases} Käufe` }, { label: 'Conversion-Rate', val: a.conversion.toFixed(1) + ' %' }, - { label: 'Ø Bestellwert', val: formatPrice(Math.round(a.aov)) }, + { label: 'Ø Bestellwert (AOV)', val: formatPrice(Math.round(a.aov)) }, + { label: 'Wiederkaufrate', val: a.repeatRate.toFixed(1) + ' %', sub: `${a.repeatBuyers}/${a.buyers} Kund:innen` }, ]; const maxRev = Math.max(...a.bySource.map(s => s.revenue), 1); +const maxSeller = Math.max(...a.bestsellers.map(b => b.qty), 1); const seriesJson = JSON.stringify(a.series); --- @@ -22,7 +24,7 @@ const seriesJson = JSON.stringify(a.series);
- {kpis.map((k) => (
{k.label}
{k.val}
letzte {days} Tage
))} + {kpis.map((k) => (
{k.label}
{k.val}
{k.sub || `letzte ${days} Tage`}
))}
@@ -61,7 +63,7 @@ const seriesJson = JSON.stringify(a.series);
-
Top-Produkte (Ansichten → Käufe)
+
Top-Produkte (Ansichten → Käufe = Conversion)
@@ -74,6 +76,53 @@ const seriesJson = JSON.stringify(a.series);
ProduktAnsichtenKäufeConversion
+ +
+
+
Bestseller (Menge & Umsatz)
+
+ + + + {a.bestsellers.length === 0 ? () : + a.bestsellers.map((b) => ( + + ))} + +
ProduktMengeUmsatz
Noch keine Verkäufe
{b.name}
{b.qty}{formatPrice(b.revenue)}
+
+
+ +
+
Top-Suchbegriffe
+
+ + + + {a.topSearches.length === 0 ? () : + a.topSearches.map((t) => ( + + ))} + +
BegriffSuchenOhne Treffer
Noch keine Suchanfragen
{t.term}{t.hits}{t.zero > 0 ? {t.zero} : '—'}
+
+
+
+ +
+
Lager-Warnungen (Bestand ≤ 5)
+
+ + + + {a.stockWarnings.length === 0 ? () : + a.stockWarnings.map((w) => ( + + ))} + +
ArtikelTypBestand
Alle Bestände im grünen Bereich
{w.name}{w.sku ? · {w.sku} : ''}{w.kind === 'variant' ? 'Variante' : 'Produkt'}{w.stock <= 0 ? 'Ausverkauft' : w.stock}
+
+
diff --git a/src/pages/admin/einstellungen/index.astro b/src/pages/admin/einstellungen/index.astro index 440a225..65e7cba 100644 --- a/src/pages/admin/einstellungen/index.astro +++ b/src/pages/admin/einstellungen/index.astro @@ -2,7 +2,7 @@ import Admin from '../../../layouts/Admin.astro'; import { adminBase } from '../../../lib/auth.js'; const base = adminBase(); -import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature, abandonedCartStats, countPendingReviews } from '../../../lib/store.js'; +import { getSettings, setSetting, resolvePaymentProvider, FEATURE_KEYS, feature, abandonedCartStats, countPendingReviews, backupStatus } from '../../../lib/store.js'; import { mailerStatus } from '../../../lib/mailer.js'; const FEATURE_LABELS = { @@ -50,6 +50,7 @@ const mail = mailerStatus(); const acStats = abandonedCartStats(); const pendingReviews = countPendingReviews(); const cronToken = (process.env.CRON_TOKEN || '').trim(); +const backup = backupStatus(); ---
@@ -129,6 +130,19 @@ const cronToken = (process.env.CRON_TOKEN || '').trim();

{pendingReviews} Bewertung(en) warten auf Freigabe — jetzt prüfen.

)} + +
+
Backup (Litestream)
+ {backup.configured ? ( + <> +

aktiv Streaming-Backup nach {backup.target}{backup.endpoint ? ` (Endpoint ${backup.endpoint})` : ''}.

+ {!backup.fullCredentials &&

Achtung: Zugangsdaten unvollständig — LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY prüfen.

} +

DB: {backup.dbPath}. Restore: litestream restore -if-replica-exists {backup.dbPath}

+ + ) : ( +

inaktiv Kein Replica konfiguriert. Setze LITESTREAM_REPLICA_URL (S3/Backblaze B2) plus Zugangsdaten als ENV, um stündliche Streaming-Backups zu aktivieren (siehe README).

+ )} +
diff --git a/src/pages/admin/inhalte/editor/[id].astro b/src/pages/admin/inhalte/editor/[id].astro index 6050203..0a3cc90 100644 --- a/src/pages/admin/inhalte/editor/[id].astro +++ b/src/pages/admin/inhalte/editor/[id].astro @@ -113,6 +113,7 @@ const dataJson = JSON.stringify(data);
+ +
diff --git a/src/pages/admin/marketing/index.astro b/src/pages/admin/marketing/index.astro index 3f8eb71..96da5e1 100644 --- a/src/pages/admin/marketing/index.astro +++ b/src/pages/admin/marketing/index.astro @@ -97,7 +97,7 @@ const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcem
-
+
@@ -120,4 +120,16 @@ const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcem
+ +
diff --git a/src/pages/admin/medien/index.astro b/src/pages/admin/medien/index.astro new file mode 100644 index 0000000..043360c --- /dev/null +++ b/src/pages/admin/medien/index.astro @@ -0,0 +1,103 @@ +--- +import Admin from '../../../layouts/Admin.astro'; +import { adminBase } from '../../../lib/auth.js'; +import { listMedia } from '../../../lib/store.js'; +const base = adminBase(); +const media = listMedia(); +const totalKb = Math.round(media.reduce((a, m) => a + (m.size || 0), 0) / 1024); +const webpCount = media.filter(m => /image\/webp/.test(m.mime || '') || /\.webp$/i.test(m.filename || '')).length; +--- + +
+
+
Dateien
{media.length}
+
WebP
{webpCount}
automatisch konvertiert
+
Speicher
{(totalKb / 1024).toFixed(1)} MB
+
+ +
+
Hochladen
+ +
+
+ +
+
Medienbibliothek
+
+ {media.length === 0 ? (
Noch keine Medien hochgeladen
) : ( +
+ {media.map((m) => ( +
+ {m.alt +
+ {(m.filename || '').slice(0, 28)} + {Math.round((m.size || 0) / 1024)} KB{m.width ? ` · ${m.width}×${m.height}` : ''} + +
+ + +
+
+
+ ))} +
+ )} +
+
+
+ + +
diff --git a/src/pages/admin/produkte/[id].astro b/src/pages/admin/produkte/[id].astro index a2b2d87..2ea81ef 100644 --- a/src/pages/admin/produkte/[id].astro +++ b/src/pages/admin/produkte/[id].astro @@ -2,15 +2,22 @@ import Admin from '../../../layouts/Admin.astro'; import { adminBase, currentUser } from '../../../lib/auth.js'; const base = adminBase(); -import { getProductById, createProduct, updateProduct, listCategories, recordAudit } from '../../../lib/store.js'; +import { getProductById, createProduct, updateProduct, listCategories, recordAudit, listVariants, setProductVariants } from '../../../lib/store.js'; const { id } = Astro.params; const isNew = id === 'neu'; let flash = ''; +function parseVariantsField(raw) { + if (!raw) return []; + try { const v = JSON.parse(String(raw)); return Array.isArray(v) ? v : []; } catch { return []; } +} + if (Astro.request.method === 'POST') { const f = await Astro.request.formData(); - const slugify = (s) => s.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const slugify = (s) => s.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const options = (() => { try { const o = JSON.parse(String(f.get('options_json') || '[]')); return Array.isArray(o) ? o : []; } catch { return []; } })(); + const variants = parseVariantsField(f.get('variants_json')); const data = { name: String(f.get('name') || ''), slug: String(f.get('slug') || '') || slugify(String(f.get('name') || 'produkt')), @@ -31,23 +38,34 @@ if (Astro.request.method === 'POST') { base_amount: f.get('base_amount') === '' || f.get('base_amount') == null ? null : parseFloat(String(f.get('base_amount')).replace(',', '.')), base_unit: String(f.get('base_unit') || ''), base_price_per: String(f.get('base_price_per') || ''), + options, }; const _me = currentUser(Astro.request); - if (isNew) { const newId = createProduct(data); recordAudit({ user: _me?.email, action: 'create', entity: 'product', entity_id: String(newId) }); return Astro.redirect(`${base}/produkte/${newId}?saved=1`); } - else { updateProduct(id, data); recordAudit({ user: _me?.email, action: 'update', entity: 'product', entity_id: String(id) }); flash = 'Produkt gespeichert.'; } + let savedId = id; + if (isNew) { savedId = createProduct(data); recordAudit({ user: _me?.email, action: 'create', entity: 'product', entity_id: String(savedId) }); } + else { updateProduct(id, data); recordAudit({ user: _me?.email, action: 'update', entity: 'product', entity_id: String(id) }); } + // Varianten persistieren (vollständige Liste vom Client; ersetzt die Matrix atomar). + try { setProductVariants(savedId, variants); } catch (e) {} + if (isNew) return Astro.redirect(`${base}/produkte/${savedId}?saved=1`); + flash = 'Produkt gespeichert.'; } const product = isNew ? null : getProductById(id); if (!isNew && !product) return Astro.redirect(base + '/produkte'); if (new URL(Astro.request.url).searchParams.get('saved')) flash = 'Produkt angelegt.'; const cats = listCategories(); -const p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '', mwst: 19, base_amount: null, base_unit: '', base_price_per: '' }; +const p = product || { name: '', slug: '', shortName: '', priceCents: 0, category: '', sizes: ['One Size'], images: [], cardImage: '', badge: '', stock: '', material: '', features: [], featured: false, sort: 99, desc: '', mwst: 19, base_amount: null, base_unit: '', base_price_per: '', options: [] }; const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',') : ''; +const variants = (!isNew && product) ? listVariants(product.id) : []; +const variantData = { options: Array.isArray(p.options) ? p.options : [], variants, basePriceCents: p.priceCents || 0 }; +const variantJson = JSON.stringify(variantData); ---
{flash &&
✓ {flash}
} -
+ + +
@@ -60,15 +78,35 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
Medien
-
-
+
+
+ + + +
+
+
+ + +
+
+ +
+
Varianten (Größe × Farbe …)
+

Definiere Optionen und ihre Werte, dann erzeuge die Varianten-Matrix. Ohne Optionen verhält sich das Produkt wie bisher (einfache „Varianten/Größen"-Liste unten).

+
+
+ + +
+
Eigenschaften
-
+
@@ -101,7 +139,7 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ', {cats.map((c) => (
-
+
@@ -110,4 +148,127 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
+ + +
diff --git a/src/pages/api/admin/media.js b/src/pages/api/admin/media.js new file mode 100644 index 0000000..8e4d8b9 --- /dev/null +++ b/src/pages/api/admin/media.js @@ -0,0 +1,47 @@ +// Session-geschützter JSON-Endpoint für den Medien-Picker (Liste, Alt-Text, Löschen). +// Bewusst getrennt vom Token-gesicherten /api/admin/[...path] — nutzt die Admin-Session. +import { listMedia, updateMediaAlt, deleteMedia, recordAudit } from '../../../lib/store.js'; +import { currentUser, canAccess } from '../../../lib/auth.js'; +import { unlinkSync, existsSync } from 'node:fs'; +import { dirname } from 'node:path'; +export const prerender = false; +function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); } +const UP_DIR = (process.env.DB_PATH ? dirname(process.env.DB_PATH) : './data') + '/uploads'; + +function guard(request) { + const user = currentUser(request); + if (!user) return { err: json({ ok: false, error: 'Nicht angemeldet' }, 401) }; + // Medien gehören zu Inhalten/Produkten — Redaktion & Inhaber dürfen. + if (!canAccess(user.role, 'inhalte') && !canAccess(user.role, 'produkte')) return { err: json({ ok: false, error: 'Keine Berechtigung' }, 403) }; + return { user }; +} + +export async function GET({ request }) { + const g = guard(request); if (g.err) return g.err; + return json({ ok: true, media: listMedia() }); +} + +export async function POST({ request }) { + const g = guard(request); if (g.err) return g.err; + let body; try { body = await request.json(); } catch { return json({ ok: false, error: 'Bad request' }, 400); } + if (body.id && body.alt !== undefined) { + const m = updateMediaAlt(body.id, body.alt); + return json({ ok: true, media: m }); + } + return json({ ok: false, error: 'Nichts zu tun' }, 400); +} + +export async function DELETE({ request }) { + const g = guard(request); if (g.err) return g.err; + let id; + try { const u = new URL(request.url); id = u.searchParams.get('id'); } catch {} + if (!id) { try { const b = await request.json(); id = b.id; } catch {} } + if (!id) return json({ ok: false, error: 'ID erforderlich' }, 400); + const m = deleteMedia(id); + if (m && m.filename) { + const full = `${UP_DIR}/${String(m.filename).replace(/\.\./g, '').replace(/^\/+/, '')}`; + if (existsSync(full)) { try { unlinkSync(full); } catch {} } + } + recordAudit({ user: g.user.email, action: 'delete', entity: 'media', entity_id: String(id) }); + return json({ ok: true }); +} diff --git a/src/pages/api/checkout.js b/src/pages/api/checkout.js index 52a49c1..25d1117 100644 --- a/src/pages/api/checkout.js +++ b/src/pages/api/checkout.js @@ -1,7 +1,7 @@ import { createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount, shippingFor, taxFromGross, getProductBySlug, markOrderPaid, getOrderById, setOrderPayment, - feature, getCustomerByEmail, attachOrderToCustomer, markCartRecoveredByEmail, + feature, getCustomerByEmail, attachOrderToCustomer, markCartRecoveredByEmail, getVariantBySku, listVariants, } from '../../lib/store.js'; import { currentCustomer } from '../../lib/customer-auth.js'; import { createPayment } from '../../lib/payments.js'; @@ -29,11 +29,24 @@ export async function POST({ request }) { const lineItems = items.map((i) => { const prod = i.slug ? getProductBySlug(i.slug) : null; + let priceCents = Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0); + const sku = String(i.sku || '').trim(); + const options = (i.variant && typeof i.variant === 'object') ? i.variant : (i.options && typeof i.options === 'object' ? i.options : null); + // Varianten-Preis serverseitig verifizieren (niemals dem Client-Preis blind vertrauen). + if (sku) { + const variant = getVariantBySku(sku); + if (variant) priceCents = (variant.price_cents == null) ? (prod ? prod.priceCents : priceCents) : variant.price_cents; + } else if (prod && Array.isArray(prod.options) && prod.options.length && options) { + const vs = listVariants(prod.id); + const match = vs.find(v => Object.keys(options).every(k => String(v.options[k]) === String(options[k]))); + if (match) priceCents = (match.price_cents == null) ? prod.priceCents : match.price_cents; + } return { slug: i.slug || (prod && prod.slug) || '', name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1), - priceCents: Math.round(Number(i.priceCents) || Number(i.price) * 100 || 0), image: i.image || '', + priceCents, image: i.image || '', mwst: prod ? Number(prod.mwst) || 0 : 19, + sku: sku || '', variant: options || null, }; }); const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0); diff --git a/src/pages/api/upload.js b/src/pages/api/upload.js index 0010d6a..17154d6 100644 --- a/src/pages/api/upload.js +++ b/src/pages/api/upload.js @@ -1,24 +1,92 @@ import { addMedia } from '../../lib/store.js'; -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; +import { dirname, extname } from 'node:path'; export const prerender = false; function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); } const UP_DIR = (process.env.DB_PATH ? dirname(process.env.DB_PATH) : './data') + '/uploads'; +const WEBP_QUALITY = Number(process.env.WEBP_QUALITY) || 82; +const MAX_WIDTH = Number(process.env.WEBP_MAX_WIDTH) || 2000; +// Diese Typen werden zu WebP konvertiert; alle anderen (webp/svg/gif/avif) werden unverändert durchgereicht. +const CONVERTIBLE = new Set(['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.bmp']); + +let _sharp = null; +async function getSharp() { + if (_sharp === null) { + try { _sharp = (await import('sharp')).default; } + catch { _sharp = false; } + } + return _sharp; +} + +function safeBase(name) { + return String(name || 'datei').replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +async function storeOne(file) { + const buf = Buffer.from(await file.arrayBuffer()); + const origName = safeBase(file.name || 'datei'); + const ext = extname(origName).toLowerCase(); + const stamp = Date.now() + '-' + Math.random().toString(36).slice(2, 7); + mkdirSync(UP_DIR, { recursive: true }); + + const sharp = await getSharp(); + // WebP-Konvertierung für geeignete Rasterbilder + if (sharp && CONVERTIBLE.has(ext)) { + try { + let img = sharp(buf, { failOn: 'none' }).rotate(); + const meta = await img.metadata(); + if (meta.width && meta.width > MAX_WIDTH) img = img.resize({ width: MAX_WIDTH }); + const out = await img.webp({ quality: WEBP_QUALITY }).toBuffer({ resolveWithObject: true }); + const webpBuf = out.data; + const baseNoExt = origName.slice(0, origName.length - ext.length) || 'bild'; + const fname = stamp + '-' + baseNoExt + '.webp'; + const fullWebp = `${UP_DIR}/${fname}`; + writeFileSync(fullWebp, webpBuf); + const url = '/uploads/' + fname; + addMedia({ filename: fname, url, mime: 'image/webp', size: webpBuf.length, width: out.info.width, height: out.info.height }); + // Original (jpg/png/…) gar nicht erst persistiert — es existierte nur im Speicher; nichts zu löschen. + return { ok: true, url, filename: fname, mime: 'image/webp', converted: true, width: out.info.width, height: out.info.height }; + } catch (e) { + // Konvertierung fehlgeschlagen → Original behalten, sauber melden (kein 500). + const fname = stamp + '-' + origName; + writeFileSync(`${UP_DIR}/${fname}`, buf); + const url = '/uploads/' + fname; + addMedia({ filename: fname, url, mime: file.type || '', size: buf.length }); + return { ok: true, url, filename: fname, mime: file.type || '', converted: false, warning: 'WebP-Konvertierung fehlgeschlagen, Original gespeichert: ' + String(e && e.message || e) }; + } + } + + // Bereits-WebP / SVG / GIF / AVIF oder kein sharp → unverändert speichern + const fname = stamp + '-' + origName; + const full = `${UP_DIR}/${fname}`; + if (existsSync(full)) { try { unlinkSync(full); } catch {} } + writeFileSync(full, buf); + const url = '/uploads/' + fname; + let width = null, height = null; + if (sharp) { try { const m = await sharp(buf, { failOn: 'none' }).metadata(); width = m.width || null; height = m.height || null; } catch {} } + addMedia({ filename: fname, url, mime: file.type || '', size: buf.length, width, height }); + return { ok: true, url, filename: fname, mime: file.type || '', converted: false, width, height }; +} export async function POST({ request }) { try { const form = await request.formData(); - const file = form.get('file'); - if (!file || typeof file === 'string') return json({ ok: false, error: 'Keine Datei' }, 400); - const buf = Buffer.from(await file.arrayBuffer()); - const safe = (file.name || 'datei').replace(/[^a-zA-Z0-9._-]/g, '_'); - const fname = Date.now() + '-' + safe; - mkdirSync(UP_DIR, { recursive: true }); - writeFileSync(`${UP_DIR}/${fname}`, buf); - const url = '/uploads/' + fname; - addMedia({ filename: fname, url, mime: file.type || '', size: buf.length }); - return json({ ok: true, url }); + // Mehrfach-Upload: alle Felder "file"/"files" sammeln. + const files = []; + for (const key of ['file', 'files']) { + for (const v of form.getAll(key)) { if (v && typeof v !== 'string') files.push(v); } + } + if (!files.length) return json({ ok: false, error: 'Keine Datei' }, 400); + + const results = []; + for (const f of files) { + try { results.push(await storeOne(f)); } + catch (e) { results.push({ ok: false, error: String(e && e.message || e), filename: safeBase(f.name) }); } + } + const first = results.find(r => r.ok) || results[0]; + // Rückwärtskompatibel: { ok, url } für Einzel-Upload; zusätzlich results[] für Mehrfach. + return json({ ok: !!(first && first.ok), url: first && first.url, results }); } catch (e) { return json({ ok: false, error: String(e && e.message || e) }, 500); } diff --git a/src/pages/produkt/[slug].astro b/src/pages/produkt/[slug].astro index 6db964d..c42240a 100644 --- a/src/pages/produkt/[slug].astro +++ b/src/pages/produkt/[slug].astro @@ -1,14 +1,16 @@ --- import Base from '../../layouts/Base.astro'; -import { getProductBySlug, listProducts, formatPrice, basePriceLabel, feature, listApprovedReviews, reviewSummary } from '../../lib/store.js'; +import { getProductBySlug, listProducts, formatPrice, basePriceLabel, feature, listApprovedReviews, reviewSummary, listVariants } from '../../lib/store.js'; const { slug } = Astro.params; const product = getProductBySlug(slug); if (!product) return Astro.redirect('/shop'); +const variants = listVariants(product.id).filter(v => v.active); +const hasVariants = (Array.isArray(product.options) && product.options.length > 0) && variants.length > 0; const related = listProducts().filter(p => p.category === product.category && p.slug !== product.slug).slice(0, 4); const gallery = product.images && product.images.length ? product.images : (product.cardImage ? [product.cardImage] : []); -const addData = { slug: product.slug, name: product.name, priceCents: product.priceCents, image: product.cardImage || gallery[0] || '', sizes: product.sizes }; +const addData = { slug: product.slug, name: product.name, priceCents: product.priceCents, image: product.cardImage || gallery[0] || '', sizes: product.sizes, hasVariants }; const mwst = (product.mwst == null ? 19 : Number(product.mwst)); const basePrice = basePriceLabel(product.priceCents, product); const wishlistOn = feature('feature_wishlist'); @@ -41,14 +43,26 @@ function starStr(n) { const full = Math.round(n); return '★★★★★'.slice {product.desc &&

{product.desc}

} - {product.sizes && product.sizes.length > 0 && product.sizes[0] !== 'One Size' && ( + {hasVariants ? ( +
({ id: v.id, options: v.options, sku: v.sku, price_cents: v.price_cents, stock: v.stock, image: v.image })), basePriceCents: product.priceCents })}> + {product.options.map((opt) => ( +
+
{opt.name}
+
+ {opt.values.map((val) => ())} +
+
+ ))} +
+
+ ) : (product.sizes && product.sizes.length > 0 && product.sizes[0] !== 'One Size' && ( <>
Variante
{product.sizes.map((s, i) => ())}
- )} + ))} {reviewsOn && rsum.count > 0 && ( @@ -151,6 +165,53 @@ function starStr(n) { const full = Math.round(n); return '★★★★★'.slice