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:
@@ -56,3 +56,20 @@ SMTP_SECURE=false
|
|||||||
CRON_TOKEN=
|
CRON_TOKEN=
|
||||||
# Karten, die älter als X Minuten sind und weder bezahlt noch erinnert wurden, werden erinnert.
|
# Karten, die älter als X Minuten sind und weder bezahlt noch erinnert wurden, werden erinnert.
|
||||||
ABANDONED_AFTER_MINUTES=30
|
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://<bucket>/<pfad>
|
||||||
|
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=
|
||||||
|
|||||||
+14
-2
@@ -1,12 +1,24 @@
|
|||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
WORKDIR /app
|
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/*
|
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 ./
|
COPY package*.json ./
|
||||||
RUN npm install --no-audit --no-fund
|
RUN npm install --no-audit --no-fund
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
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
|
RUN mkdir -p /data
|
||||||
EXPOSE 4321
|
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
|
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"]
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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 <CRON_TOKEN>` 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.
|
- **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 <CRON_TOKEN>` 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.
|
- **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.
|
- **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).
|
- **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 `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://<bucket>/<pfad>
|
||||||
|
LITESTREAM_ACCESS_KEY_ID=<keyID>
|
||||||
|
LITESTREAM_SECRET_ACCESS_KEY=<applicationKey>
|
||||||
|
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
|
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Executable
+26
@@ -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
|
||||||
@@ -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://<bucket>/<pfad>
|
||||||
|
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}
|
||||||
Generated
+3
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hd-commerce",
|
"name": "hd-commerce",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hd-commerce",
|
"name": "hd-commerce",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.1.3",
|
"@astrojs/node": "^9.1.3",
|
||||||
"@fontsource-variable/fraunces": "^5.1.0",
|
"@fontsource-variable/fraunces": "^5.1.0",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"astro": "^5.6.0",
|
"astro": "^5.6.0",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^17.5.0"
|
"stripe": "^17.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -609,7 +610,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -4904,7 +4904,6 @@
|
|||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "hd-commerce",
|
"name": "hd-commerce",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)",
|
"description": "hd-commerce — neutrales SQLite-Commerce-Backend (Admin + API + Demo-Storefront)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"astro": "^5.6.0",
|
"astro": "^5.6.0",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^17.5.0"
|
"stripe": "^17.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
function add(item) {
|
function add(item) {
|
||||||
var c = read();
|
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);
|
if (ex) ex.qty += item.qty || 1; else c.push(item);
|
||||||
write(c);
|
write(c);
|
||||||
track('add_to_cart', (item.priceCents || 0) * (item.qty || 1), { slug: item.slug });
|
track('add_to_cart', (item.priceCents || 0) * (item.qty || 1), { slug: item.slug });
|
||||||
@@ -44,7 +44,14 @@
|
|||||||
updateBadge();
|
updateBadge();
|
||||||
document.querySelectorAll('[data-add-to-cart]').forEach(function (btn) {
|
document.querySelectorAll('[data-add-to-cart]').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
|
if (btn.disabled) return;
|
||||||
var p = JSON.parse(btn.getAttribute('data-product') || '{}');
|
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 sizeSel = document.querySelector('.size-chip.active');
|
||||||
var size = sizeSel ? sizeSel.getAttribute('data-size') : (p.sizes && p.sizes[0]) || 'One Size';
|
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 });
|
add({ slug: p.slug, name: p.name, size: size, priceCents: p.priceCents, image: p.image, qty: 1 });
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const allNav = [
|
|||||||
{ key:'marketing', label:'Marketing', href: base + '/marketing', icon:'M3 11l18-5v12l-7-2v4l-4-1v-3L3 11Z' },
|
{ 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:'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:'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' },
|
{ 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');
|
const reviewsOn = feature('feature_reviews');
|
||||||
|
|||||||
+22
-6
@@ -19,7 +19,9 @@ export function authOk(request) {
|
|||||||
|
|
||||||
// ---- Ressourcen-Definitionen für das Manifest ----
|
// ---- Ressourcen-Definitionen für das Manifest ----
|
||||||
export const RESOURCES = {
|
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[]'] },
|
pages: { rw: true, fields: ['slug', 'title', 'body', 'type(content|legal)', 'active', 'sort', 'blocks[]'] },
|
||||||
slides: { rw: true, fields: ['image', 'headline', 'subline', 'link', 'sort', 'active'] },
|
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'] },
|
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 'settings': return store.getSettings();
|
||||||
case 'reviews': return store.listReviews();
|
case 'reviews': return store.listReviews();
|
||||||
case 'abandoned_carts': return store.listAbandonedCarts();
|
case 'abandoned_carts': return store.listAbandonedCarts();
|
||||||
|
case 'media': return store.listMedia();
|
||||||
|
case 'product_variants': return store.listAllVariants();
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export function getResource(name, id) {
|
export function getResource(name, id) {
|
||||||
switch (name) {
|
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 'pages': return /^\d+$/.test(String(id)) ? store.getPageById(id) : store.getPageBySlug(id);
|
||||||
case 'slides': return store.getSlideById(id);
|
case 'slides': return store.getSlideById(id);
|
||||||
case 'popups': return store.getPopupById(id);
|
case 'popups': return store.getPopupById(id);
|
||||||
@@ -59,6 +63,8 @@ export function getResource(name, id) {
|
|||||||
case 'orders': return store.getOrderById(id);
|
case 'orders': return store.getOrderById(id);
|
||||||
case 'customers': return store.getCustomerById(id);
|
case 'customers': return store.getCustomerById(id);
|
||||||
case 'reviews': return store.getReviewById(id);
|
case 'reviews': return store.getReviewById(id);
|
||||||
|
case 'media': return store.getMediaById(id);
|
||||||
|
case 'product_variants': return store.listVariants(id);
|
||||||
default: return null;
|
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.
|
// upsert: bei id -> update, sonst create. Für products/pages erlaubt auch slug als Schlüssel.
|
||||||
export function upsertResource(name, body) {
|
export function upsertResource(name, body) {
|
||||||
if (name === 'products') {
|
if (name === 'products') {
|
||||||
if (body.id) { store.updateProduct(body.id, body); return { id: Number(body.id), ...store.getProductById(body.id) }; }
|
const applyVariants = (pid) => { if (Array.isArray(body.variants)) { try { store.setProductVariants(pid, body.variants); } catch {} } };
|
||||||
if (body.slug) { const ex = store.getProductBySlug(body.slug); if (ex) { store.updateProduct(ex.id, { ...ex, ...body }); return store.getProductById(ex.id); } }
|
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) }; }
|
||||||
const id = store.createProduct(body); return store.getProductById(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 (name === 'pages') {
|
||||||
if (body.id) { store.updatePage(body.id, body); return store.getPageById(body.id); }
|
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);
|
if (body.approved) store.setReviewApproved(res.id, 1);
|
||||||
return store.getReviewById(res.id);
|
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 (name === 'shipping_zones') {
|
||||||
if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); }
|
if (body.id) { store.updateShippingZone(body.id, body); return store.getShippingZoneById(body.id); }
|
||||||
const id = store.createShippingZone(body); return store.getShippingZoneById(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' });
|
ep.push({ method: 'POST', path: '/api/admin/pages/{id}/blocks', desc: 'Block-Array einer Seite setzen' });
|
||||||
return {
|
return {
|
||||||
name: 'hd-commerce Admin API',
|
name: 'hd-commerce Admin API',
|
||||||
version: '2.3.0',
|
version: '2.4.0',
|
||||||
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
|
auth: 'Authorization: Bearer <HDC_API_TOKEN>',
|
||||||
base_url: origin || '',
|
base_url: origin || '',
|
||||||
resources: RESOURCES,
|
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).',
|
'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.',
|
'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).',
|
'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.',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -88,8 +88,8 @@ export function currentUser(request) {
|
|||||||
// --- Rollen-Gate ---
|
// --- Rollen-Gate ---
|
||||||
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
|
// owner: alles · redaktion: Produkte/Inhalte/Marketing · versand: nur Bestellungen
|
||||||
const ROLE_SECTIONS = {
|
const ROLE_SECTIONS = {
|
||||||
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'],
|
owner: ['dashboard', 'bestellungen', 'produkte', 'kunden', 'analytics', 'marketing', 'rabatte', 'inhalte', 'medien', 'versandzonen', 'bewertungen', 'einstellungen', 'nutzer', 'audit'],
|
||||||
redaktion: ['dashboard', 'produkte', 'inhalte', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'],
|
redaktion: ['dashboard', 'produkte', 'inhalte', 'medien', 'marketing', 'rabatte', 'analytics', 'versandzonen', 'bewertungen'],
|
||||||
versand: ['bestellungen'],
|
versand: ['bestellungen'],
|
||||||
};
|
};
|
||||||
export function canAccess(role, section) {
|
export function canAccess(role, section) {
|
||||||
|
|||||||
+166
-8
@@ -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 ----------
|
// ---------- 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 O = (r) => r && ({ ...r, items: JSON.parse(r.items || '[]') });
|
||||||
const E = (r) => r && ({ ...r, meta: JSON.parse(r.meta || '{}') });
|
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_amount: (d.base_amount === '' || d.base_amount == null) ? null : Number(d.base_amount),
|
||||||
base_unit: d.base_unit || '',
|
base_unit: d.base_unit || '',
|
||||||
base_price_per: d.base_price_per || '',
|
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) {
|
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)
|
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)`).run(normProduct(d));
|
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;
|
return r.lastInsertRowid;
|
||||||
}
|
}
|
||||||
export function updateProduct(id, d) {
|
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) });
|
.run({ ...normProduct(d), id: Number(id) });
|
||||||
return 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 ----------
|
// ---------- orders ----------
|
||||||
export const listOrders = () => db.prepare('SELECT * FROM orders ORDER BY datetime(created_at) DESC, id DESC').all().map(O);
|
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();
|
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 ----------
|
// ---------- media ----------
|
||||||
export function addMedia(d) {
|
export function addMedia(d) {
|
||||||
return db.prepare('INSERT INTO media (filename,url,mime,size,created_at) VALUES (?,?,?,?,?)')
|
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, new Date().toISOString()).lastInsertRowid;
|
.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 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 ----------
|
// ---------- events / analytics ----------
|
||||||
export function recordEvent({ type, path = '', referrer = '', utm_source = '', utm_medium = '', utm_campaign = '', session = '', value_cents = 0, meta = {} }) {
|
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 });
|
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 {
|
return {
|
||||||
days, visitors, pageviews, productViews, addToCart, checkoutStart, purchases, revenue, conversion, aov,
|
days, visitors, pageviews, productViews, addToCart, checkoutStart, purchases, revenue, conversion, aov,
|
||||||
|
repeatRate, buyers, repeatBuyers,
|
||||||
funnel: [
|
funnel: [
|
||||||
{ label: 'Aufrufe', value: pageviews },
|
{ label: 'Aufrufe', value: pageviews },
|
||||||
{ label: 'Produktansichten', value: productViews },
|
{ label: 'Produktansichten', value: productViews },
|
||||||
@@ -586,7 +744,7 @@ export function analyticsSummary(days = 30) {
|
|||||||
{ label: 'Checkout', value: checkoutStart },
|
{ label: 'Checkout', value: checkoutStart },
|
||||||
{ label: 'Kauf', value: purchases },
|
{ label: 'Kauf', value: purchases },
|
||||||
],
|
],
|
||||||
bySource, topProducts, series,
|
bySource, topProducts, series, topSearches, bestsellers, stockWarnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ function sectionOf(adminInner) {
|
|||||||
const seg = adminInner.replace(/^\//, '').split('/')[0] || 'dashboard';
|
const seg = adminInner.replace(/^\//, '').split('/')[0] || 'dashboard';
|
||||||
const map = {
|
const map = {
|
||||||
'': 'dashboard', 'bestellungen': 'bestellungen', 'produkte': 'produkte', 'kunden': 'kunden',
|
'': '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',
|
'nutzer': 'nutzer', 'audit': 'audit', 'versand': 'versandzonen', 'bewertungen': 'bewertungen', 'konto': 'dashboard', 'login': 'login', 'logout': 'logout',
|
||||||
};
|
};
|
||||||
return map[seg] || 'dashboard';
|
return map[seg] || 'dashboard';
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ const accent = getSetting('brand_accent', '#b8566a');
|
|||||||
const maxFunnel = Math.max(...a.funnel.map(f => f.value), 1);
|
const maxFunnel = Math.max(...a.funnel.map(f => f.value), 1);
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: 'Besucher', val: a.visitors.toLocaleString('de-DE') },
|
{ 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: '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 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);
|
const seriesJson = JSON.stringify(a.series);
|
||||||
---
|
---
|
||||||
<Admin title="Analytics" active="analytics" crumbs={[{ label: 'Analytics' }]}>
|
<Admin title="Analytics" active="analytics" crumbs={[{ label: 'Analytics' }]}>
|
||||||
@@ -22,7 +24,7 @@ const seriesJson = JSON.stringify(a.series);
|
|||||||
</div>
|
</div>
|
||||||
<div class="s-stack">
|
<div class="s-stack">
|
||||||
<div class="s-kpis">
|
<div class="s-kpis">
|
||||||
{kpis.map((k) => (<div class="s-kpi"><div class="s-kpi-label">{k.label}</div><div class="s-kpi-val">{k.val}</div><div class="s-kpi-sub">letzte {days} Tage</div></div>))}
|
{kpis.map((k) => (<div class="s-kpi"><div class="s-kpi-label">{k.label}</div><div class="s-kpi-val">{k.val}</div><div class="s-kpi-sub">{k.sub || `letzte ${days} Tage`}</div></div>))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="s-card">
|
<div class="s-card">
|
||||||
@@ -61,7 +63,7 @@ const seriesJson = JSON.stringify(a.series);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="s-card">
|
<div class="s-card">
|
||||||
<div class="s-card-head">Top-Produkte (Ansichten → Käufe)</div>
|
<div class="s-card-head">Top-Produkte (Ansichten → Käufe = Conversion)</div>
|
||||||
<div class="s-table-wrap">
|
<div class="s-table-wrap">
|
||||||
<table class="s-table">
|
<table class="s-table">
|
||||||
<thead><tr><th>Produkt</th><th class="num">Ansichten</th><th class="num">Käufe</th><th class="num">Conversion</th></tr></thead>
|
<thead><tr><th>Produkt</th><th class="num">Ansichten</th><th class="num">Käufe</th><th class="num">Conversion</th></tr></thead>
|
||||||
@@ -74,6 +76,53 @@ const seriesJson = JSON.stringify(a.series);
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="s-grid" style="grid-template-columns:1fr 1fr">
|
||||||
|
<div class="s-card">
|
||||||
|
<div class="s-card-head">Bestseller (Menge & Umsatz)</div>
|
||||||
|
<div class="s-table-wrap">
|
||||||
|
<table class="s-table">
|
||||||
|
<thead><tr><th>Produkt</th><th class="num">Menge</th><th class="num">Umsatz</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{a.bestsellers.length === 0 ? (<tr><td colspan="3" class="s-empty">Noch keine Verkäufe</td></tr>) :
|
||||||
|
a.bestsellers.map((b) => (
|
||||||
|
<tr><td><b>{b.name}</b><div class="s-bar-track" style="margin-top:6px"><i style={`width:${(b.qty / maxSeller) * 100}%`}></i></div></td><td class="num">{b.qty}</td><td class="num"><b>{formatPrice(b.revenue)}</b></td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-card">
|
||||||
|
<div class="s-card-head">Top-Suchbegriffe</div>
|
||||||
|
<div class="s-table-wrap">
|
||||||
|
<table class="s-table">
|
||||||
|
<thead><tr><th>Begriff</th><th class="num">Suchen</th><th class="num">Ohne Treffer</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{a.topSearches.length === 0 ? (<tr><td colspan="3" class="s-empty">Noch keine Suchanfragen</td></tr>) :
|
||||||
|
a.topSearches.map((t) => (
|
||||||
|
<tr><td><b>{t.term}</b></td><td class="num">{t.hits}</td><td class="num">{t.zero > 0 ? <span class="s-badge red">{t.zero}</span> : '—'}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-card">
|
||||||
|
<div class="s-card-head">Lager-Warnungen (Bestand ≤ 5)</div>
|
||||||
|
<div class="s-table-wrap">
|
||||||
|
<table class="s-table">
|
||||||
|
<thead><tr><th>Artikel</th><th>Typ</th><th class="num">Bestand</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{a.stockWarnings.length === 0 ? (<tr><td colspan="3" class="s-empty">Alle Bestände im grünen Bereich</td></tr>) :
|
||||||
|
a.stockWarnings.map((w) => (
|
||||||
|
<tr><td><b>{w.name}</b>{w.sku ? <span class="s-muted"> · {w.sku}</span> : ''}</td><td>{w.kind === 'variant' ? 'Variante' : 'Produkt'}</td><td class="num"><span class={`s-badge ${w.stock <= 0 ? 'red' : 'gray'}`}>{w.stock <= 0 ? 'Ausverkauft' : w.stock}</span></td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" is:inline></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" is:inline></script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Admin from '../../../layouts/Admin.astro';
|
import Admin from '../../../layouts/Admin.astro';
|
||||||
import { adminBase } from '../../../lib/auth.js';
|
import { adminBase } from '../../../lib/auth.js';
|
||||||
const base = adminBase();
|
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';
|
import { mailerStatus } from '../../../lib/mailer.js';
|
||||||
|
|
||||||
const FEATURE_LABELS = {
|
const FEATURE_LABELS = {
|
||||||
@@ -50,6 +50,7 @@ const mail = mailerStatus();
|
|||||||
const acStats = abandonedCartStats();
|
const acStats = abandonedCartStats();
|
||||||
const pendingReviews = countPendingReviews();
|
const pendingReviews = countPendingReviews();
|
||||||
const cronToken = (process.env.CRON_TOKEN || '').trim();
|
const cronToken = (process.env.CRON_TOKEN || '').trim();
|
||||||
|
const backup = backupStatus();
|
||||||
---
|
---
|
||||||
<Admin title="Einstellungen" active="einstellungen" crumbs={[{ label: 'Einstellungen' }]}>
|
<Admin title="Einstellungen" active="einstellungen" crumbs={[{ label: 'Einstellungen' }]}>
|
||||||
<div class="s-stack">
|
<div class="s-stack">
|
||||||
@@ -129,6 +130,19 @@ const cronToken = (process.env.CRON_TOKEN || '').trim();
|
|||||||
<p class="s-help"><b>{pendingReviews}</b> Bewertung(en) warten auf Freigabe — <a href={base + '/bewertungen'}>jetzt prüfen</a>.</p>
|
<p class="s-help"><b>{pendingReviews}</b> Bewertung(en) warten auf Freigabe — <a href={base + '/bewertungen'}>jetzt prüfen</a>.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div class="s-card s-card-pad">
|
||||||
|
<div class="s-section-title" style="margin-bottom:8px">Backup (Litestream)</div>
|
||||||
|
{backup.configured ? (
|
||||||
|
<>
|
||||||
|
<p class="s-help" style="margin-bottom:6px"><span class="s-badge green">aktiv</span> Streaming-Backup nach <b>{backup.target}</b>{backup.endpoint ? ` (Endpoint ${backup.endpoint})` : ''}.</p>
|
||||||
|
{!backup.fullCredentials && <p class="s-help" style="color:#b3261e">Achtung: Zugangsdaten unvollständig — LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY prüfen.</p>}
|
||||||
|
<p class="s-help">DB: <code>{backup.dbPath}</code>. Restore: <code>litestream restore -if-replica-exists {backup.dbPath}</code></p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p class="s-help"><span class="s-badge gray">inaktiv</span> Kein Replica konfiguriert. Setze <b>LITESTREAM_REPLICA_URL</b> (S3/Backblaze B2) plus Zugangsdaten als ENV, um stündliche Streaming-Backups zu aktivieren (siehe README).</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const dataJson = JSON.stringify(data);
|
|||||||
|
|
||||||
<div class="s-toasts" id="toasts" aria-live="polite"></div>
|
<div class="s-toasts" id="toasts" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script src="/media-picker.js" is:inline></script>
|
||||||
<script is:inline define:vars={{ dataJson }}>
|
<script is:inline define:vars={{ dataJson }}>
|
||||||
(function () {
|
(function () {
|
||||||
var D = JSON.parse(dataJson);
|
var D = JSON.parse(dataJson);
|
||||||
@@ -258,7 +259,7 @@ const dataJson = JSON.stringify(data);
|
|||||||
else if (f.type === 'select') h += '<select class="s-select" data-f="' + f.name + '">' + f.options.map(function (o) { return '<option value="' + esc(o) + '"' + (String(val) === String(o) ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('') + '</select>';
|
else if (f.type === 'select') h += '<select class="s-select" data-f="' + f.name + '">' + f.options.map(function (o) { return '<option value="' + esc(o) + '"' + (String(val) === String(o) ? ' selected' : '') + '>' + esc(o) + '</option>'; }).join('') + '</select>';
|
||||||
else if (f.type === 'number') h += '<input class="s-input" type="number" data-f="' + f.name + '" value="' + esc(val) + '">';
|
else if (f.type === 'number') h += '<input class="s-input" type="number" data-f="' + f.name + '" value="' + esc(val) + '">';
|
||||||
else if (f.type === 'image') h += '<div class="ed-imgrow"><input class="s-input" data-f="' + f.name + '" value="' + esc(val) + '" placeholder="Bild-URL"><button class="s-btn s-btn-sm ed-mediabtn" data-pick="' + f.name + '" type="button">📷</button></div>';
|
else if (f.type === 'image') h += '<div class="ed-imgrow"><input class="s-input" data-f="' + f.name + '" value="' + esc(val) + '" placeholder="Bild-URL"><button class="s-btn s-btn-sm ed-mediabtn" data-pick="' + f.name + '" type="button">📷</button></div>';
|
||||||
else if (f.type === 'imagelist') h += '<textarea class="s-textarea" data-fl="' + f.name + '" placeholder="Eine Bild-URL pro Zeile" style="min-height:90px">' + esc((val || []).join('\n')) + '</textarea>';
|
else if (f.type === 'imagelist') h += '<textarea class="s-textarea" data-fl="' + f.name + '" placeholder="Eine Bild-URL pro Zeile" style="min-height:90px">' + esc((val || []).join('\n')) + '</textarea><button class="s-btn s-btn-sm ed-mediabtn" data-pickmulti="' + f.name + '" type="button" style="margin-top:6px">📷 Aus Medien wählen</button>';
|
||||||
else if (f.type === 'features') {
|
else if (f.type === 'features') {
|
||||||
(val || []).forEach(function (it, fi) {
|
(val || []).forEach(function (it, fi) {
|
||||||
h += '<input class="s-input" data-feat="' + fi + '" data-featk="title" value="' + esc(it.title) + '" placeholder="Titel" style="margin-bottom:5px"><input class="s-input" data-feat="' + fi + '" data-featk="text" value="' + esc(it.text) + '" placeholder="Text" style="margin-bottom:10px">';
|
h += '<input class="s-input" data-feat="' + fi + '" data-featk="title" value="' + esc(it.title) + '" placeholder="Titel" style="margin-bottom:5px"><input class="s-input" data-feat="' + fi + '" data-featk="text" value="' + esc(it.text) + '" placeholder="Text" style="margin-bottom:10px">';
|
||||||
@@ -285,8 +286,24 @@ const dataJson = JSON.stringify(data);
|
|||||||
});
|
});
|
||||||
setEl.querySelectorAll('[data-pick]').forEach(function (el) {
|
setEl.querySelectorAll('[data-pick]').forEach(function (el) {
|
||||||
el.addEventListener('click', function () {
|
el.addEventListener('click', function () {
|
||||||
var url = prompt('Bild-URL eingeben (oder aus der Medien-Bibliothek kopieren):', blocks[selected][el.getAttribute('data-pick')] || '');
|
var field = el.getAttribute('data-pick');
|
||||||
if (url != null) { blocks[selected][el.getAttribute('data-pick')] = url; renderSettings(); renderPreview(); }
|
if (window.HDCMedia) {
|
||||||
|
window.HDCMedia.pick({ multiple: false, onPick: function (url) { blocks[selected][field] = url; renderSettings(); renderPreview(); } });
|
||||||
|
} else {
|
||||||
|
var url = prompt('Bild-URL eingeben:', blocks[selected][field] || '');
|
||||||
|
if (url != null) { blocks[selected][field] = url; renderSettings(); renderPreview(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setEl.querySelectorAll('[data-pickmulti]').forEach(function (el) {
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var field = el.getAttribute('data-pickmulti');
|
||||||
|
if (!window.HDCMedia) return;
|
||||||
|
window.HDCMedia.pick({ multiple: true, onPick: function (urls) {
|
||||||
|
var cur = Array.isArray(blocks[selected][field]) ? blocks[selected][field] : [];
|
||||||
|
blocks[selected][field] = cur.concat(urls);
|
||||||
|
renderSettings(); renderPreview();
|
||||||
|
} });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media',
|
|||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="_action" value="slide" />
|
<input type="hidden" name="_action" value="slide" />
|
||||||
{es && <input type="hidden" name="id" value={sl.id} />}
|
{es && <input type="hidden" name="id" value={sl.id} />}
|
||||||
<div class="s-field"><label class="s-label">Bild-URL</label><input class="s-input" name="image" value={sl.image} /></div>
|
<div class="s-field"><label class="s-label">Bild</label><div class="s-imgfield"><input class="s-input" name="image" id="slideImageInput" value={sl.image} placeholder="Bild-URL" /><button type="button" class="s-btn s-btn-sm" data-pick-into="slideImageInput">📷</button></div></div>
|
||||||
<div class="s-field"><label class="s-label">Headline</label><input class="s-input" name="headline" value={sl.headline} required /></div>
|
<div class="s-field"><label class="s-label">Headline</label><input class="s-input" name="headline" value={sl.headline} required /></div>
|
||||||
<div class="s-field"><label class="s-label">Subline</label><input class="s-input" name="subline" value={sl.subline} /></div>
|
<div class="s-field"><label class="s-label">Subline</label><input class="s-input" name="subline" value={sl.subline} /></div>
|
||||||
<div class="s-field"><label class="s-label">Link</label><input class="s-input" name="link" value={sl.link} placeholder="/shop" /></div>
|
<div class="s-field"><label class="s-label">Link</label><input class="s-input" name="link" value={sl.link} placeholder="/shop" /></div>
|
||||||
@@ -179,4 +179,16 @@ const tabs = [['pages', 'Seiten & Rechtstexte'], ['slider', 'Slider'], ['media',
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/media-picker.js" is:inline></script>
|
||||||
|
<script is:inline>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('[data-pick-into]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var inp = document.getElementById(btn.getAttribute('data-pick-into'));
|
||||||
|
if (!inp || !window.HDCMedia) return;
|
||||||
|
window.HDCMedia.pick({ multiple: false, onPick: function (url) { inp.value = url; } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</Admin>
|
</Admin>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcem
|
|||||||
</div>
|
</div>
|
||||||
<div class="s-field"><label class="s-label">Headline</label><input class="s-input" name="headline" value={e.headline} /></div>
|
<div class="s-field"><label class="s-label">Headline</label><input class="s-input" name="headline" value={e.headline} /></div>
|
||||||
<div class="s-field"><label class="s-label">Text</label><textarea class="s-textarea" name="body">{e.body}</textarea></div>
|
<div class="s-field"><label class="s-label">Text</label><textarea class="s-textarea" name="body">{e.body}</textarea></div>
|
||||||
<div class="s-field"><label class="s-label">Bild-URL (optional)</label><input class="s-input" name="image" value={e.image} /></div>
|
<div class="s-field"><label class="s-label">Bild (optional)</label><div class="s-imgfield"><input class="s-input" name="image" id="popupImageInput" value={e.image} placeholder="Bild-URL" /><button type="button" class="s-btn s-btn-sm" data-pick-into="popupImageInput">📷</button></div></div>
|
||||||
<div class="s-form-grid">
|
<div class="s-form-grid">
|
||||||
<div class="s-field"><label class="s-label">CTA-Text</label><input class="s-input" name="cta_text" value={e.cta_text} /></div>
|
<div class="s-field"><label class="s-label">CTA-Text</label><input class="s-input" name="cta_text" value={e.cta_text} /></div>
|
||||||
<div class="s-field"><label class="s-label">CTA-Link</label><input class="s-input" name="cta_url" value={e.cta_url} /></div>
|
<div class="s-field"><label class="s-label">CTA-Link</label><input class="s-input" name="cta_url" value={e.cta_url} /></div>
|
||||||
@@ -120,4 +120,16 @@ const types = [['newsletter', 'Newsletter'], ['discount', 'Rabatt'], ['announcem
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/media-picker.js" is:inline></script>
|
||||||
|
<script is:inline>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('[data-pick-into]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var inp = document.getElementById(btn.getAttribute('data-pick-into'));
|
||||||
|
if (!inp || !window.HDCMedia) return;
|
||||||
|
window.HDCMedia.pick({ multiple: false, onPick: function (url) { inp.value = url; } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</Admin>
|
</Admin>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
---
|
||||||
|
<Admin title="Medien" active="medien" crumbs={[{ label: 'Medien' }]}>
|
||||||
|
<div class="s-stack">
|
||||||
|
<div class="s-kpis">
|
||||||
|
<div class="s-kpi"><div class="s-kpi-label">Dateien</div><div class="s-kpi-val">{media.length}</div></div>
|
||||||
|
<div class="s-kpi"><div class="s-kpi-label">WebP</div><div class="s-kpi-val">{webpCount}</div><div class="s-kpi-sub">automatisch konvertiert</div></div>
|
||||||
|
<div class="s-kpi"><div class="s-kpi-label">Speicher</div><div class="s-kpi-val">{(totalKb / 1024).toFixed(1)} MB</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-card s-card-pad">
|
||||||
|
<div class="s-section-title" style="margin-bottom:10px">Hochladen</div>
|
||||||
|
<label class="mp-drop" id="mediaDrop" style="display:block;cursor:pointer;margin:0">
|
||||||
|
Bilder hierher ziehen oder klicken zum Auswählen — JPG/PNG werden automatisch zu <b>WebP</b> konvertiert (Original wird verworfen).
|
||||||
|
<input type="file" id="mediaFiles" accept="image/*" multiple hidden />
|
||||||
|
</label>
|
||||||
|
<div id="upMsg" class="s-help" style="margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-card">
|
||||||
|
<div class="s-card-head">Medienbibliothek</div>
|
||||||
|
<div class="s-card-pad">
|
||||||
|
{media.length === 0 ? (<div class="s-empty">Noch keine Medien hochgeladen</div>) : (
|
||||||
|
<div class="s-media-grid" id="mediaGrid">
|
||||||
|
{media.map((m) => (
|
||||||
|
<div class="s-media-item" data-id={m.id}>
|
||||||
|
<img src={m.url} alt={m.alt || m.filename} />
|
||||||
|
<div class="mi">
|
||||||
|
<span class="s-muted" title={m.filename}>{(m.filename || '').slice(0, 28)}</span>
|
||||||
|
<span class="s-muted" style="font-size:10.5px">{Math.round((m.size || 0) / 1024)} KB{m.width ? ` · ${m.width}×${m.height}` : ''}</span>
|
||||||
|
<input class="s-input" style="padding:5px 7px;font-size:11px" data-alt={m.id} value={m.alt || ''} placeholder="Alt-Text (SEO/Barrierefreiheit)" />
|
||||||
|
<div style="display:flex;gap:5px">
|
||||||
|
<button class="s-btn s-btn-sm" type="button" data-copy={m.url} style="flex:1">URL kopieren</button>
|
||||||
|
<button class="s-btn s-btn-sm" type="button" data-del={m.id} title="Löschen">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
(function () {
|
||||||
|
var drop = document.getElementById('mediaDrop');
|
||||||
|
var inp = document.getElementById('mediaFiles');
|
||||||
|
var msg = document.getElementById('upMsg');
|
||||||
|
if (drop) {
|
||||||
|
['dragover', 'dragenter'].forEach(function (e) { drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.add('over'); }); });
|
||||||
|
['dragleave', 'drop'].forEach(function (e) { drop.addEventListener(e, function (ev) { ev.preventDefault(); drop.classList.remove('over'); }); });
|
||||||
|
drop.addEventListener('drop', function (ev) { if (ev.dataTransfer && ev.dataTransfer.files.length) upload(ev.dataTransfer.files); });
|
||||||
|
}
|
||||||
|
if (inp) inp.addEventListener('change', function () { if (inp.files.length) upload(inp.files); });
|
||||||
|
|
||||||
|
function upload(files) {
|
||||||
|
var fd = new FormData();
|
||||||
|
Array.prototype.forEach.call(files, function (f) { fd.append('files', f); });
|
||||||
|
msg.textContent = '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;
|
||||||
|
var bad = d.results.filter(function (r) { return !r.ok; }).length;
|
||||||
|
msg.textContent = 'Hochgeladen' + (conv ? ' (' + conv + '× WebP)' : '') + (bad ? ', ' + bad + ' Fehler' : '') + '.';
|
||||||
|
setTimeout(function () { location.reload(); }, 700);
|
||||||
|
} else { msg.textContent = 'Fehler: ' + ((d && d.error) || 'unbekannt'); }
|
||||||
|
}).catch(function () { msg.textContent = 'Upload fehlgeschlagen.'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
var grid = document.getElementById('mediaGrid');
|
||||||
|
if (grid) {
|
||||||
|
grid.querySelectorAll('[data-copy]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { try { navigator.clipboard.writeText(location.origin + b.getAttribute('data-copy')); b.textContent = 'Kopiert!'; setTimeout(function () { b.textContent = 'URL kopieren'; }, 1200); } catch (e) {} });
|
||||||
|
});
|
||||||
|
grid.querySelectorAll('[data-del]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
if (!confirm('Dieses Medium wirklich löschen?')) return;
|
||||||
|
fetch('/api/admin/media?id=' + b.getAttribute('data-del'), { method: 'DELETE' }).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
|
if (d.ok) { var item = b.closest('.s-media-item'); if (item) item.remove(); } else alert(d.error || 'Löschen fehlgeschlagen.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
grid.querySelectorAll('[data-alt]').forEach(function (el) {
|
||||||
|
var t;
|
||||||
|
el.addEventListener('input', function () {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(function () {
|
||||||
|
fetch('/api/admin/media', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: Number(el.getAttribute('data-alt')), alt: el.value }) });
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</Admin>
|
||||||
@@ -2,15 +2,22 @@
|
|||||||
import Admin from '../../../layouts/Admin.astro';
|
import Admin from '../../../layouts/Admin.astro';
|
||||||
import { adminBase, currentUser } from '../../../lib/auth.js';
|
import { adminBase, currentUser } from '../../../lib/auth.js';
|
||||||
const base = adminBase();
|
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 { id } = Astro.params;
|
||||||
const isNew = id === 'neu';
|
const isNew = id === 'neu';
|
||||||
let flash = '';
|
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') {
|
if (Astro.request.method === 'POST') {
|
||||||
const f = await Astro.request.formData();
|
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 = {
|
const data = {
|
||||||
name: String(f.get('name') || ''),
|
name: String(f.get('name') || ''),
|
||||||
slug: String(f.get('slug') || '') || slugify(String(f.get('name') || 'produkt')),
|
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_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_unit: String(f.get('base_unit') || ''),
|
||||||
base_price_per: String(f.get('base_price_per') || ''),
|
base_price_per: String(f.get('base_price_per') || ''),
|
||||||
|
options,
|
||||||
};
|
};
|
||||||
const _me = currentUser(Astro.request);
|
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`); }
|
let savedId = id;
|
||||||
else { updateProduct(id, data); recordAudit({ user: _me?.email, action: 'update', entity: 'product', entity_id: String(id) }); flash = 'Produkt gespeichert.'; }
|
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);
|
const product = isNew ? null : getProductById(id);
|
||||||
if (!isNew && !product) return Astro.redirect(base + '/produkte');
|
if (!isNew && !product) return Astro.redirect(base + '/produkte');
|
||||||
if (new URL(Astro.request.url).searchParams.get('saved')) flash = 'Produkt angelegt.';
|
if (new URL(Astro.request.url).searchParams.get('saved')) flash = 'Produkt angelegt.';
|
||||||
const cats = listCategories();
|
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 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);
|
||||||
---
|
---
|
||||||
<Admin title={isNew ? 'Neues Produkt' : (product.shortName || product.name)} active="produkte" crumbs={[{ label: 'Produkte', href: base + '/produkte' }, { label: isNew ? 'Neu' : (product.shortName || product.name) }]}>
|
<Admin title={isNew ? 'Neues Produkt' : (product.shortName || product.name)} active="produkte" crumbs={[{ label: 'Produkte', href: base + '/produkte' }, { label: isNew ? 'Neu' : (product.shortName || product.name) }]}>
|
||||||
<div class="s-stack">
|
<div class="s-stack">
|
||||||
{flash && <div class="s-flash">✓ {flash}</div>}
|
{flash && <div class="s-flash">✓ {flash}</div>}
|
||||||
<form method="POST" class="s-two-col">
|
<form method="POST" class="s-two-col" id="prodForm">
|
||||||
|
<input type="hidden" name="options_json" id="optionsJson" />
|
||||||
|
<input type="hidden" name="variants_json" id="variantsJson" />
|
||||||
<div class="s-stack">
|
<div class="s-stack">
|
||||||
<div class="s-card s-card-pad">
|
<div class="s-card s-card-pad">
|
||||||
<div class="s-field"><label class="s-label">Produktname</label><input class="s-input" name="name" value={p.name} required /></div>
|
<div class="s-field"><label class="s-label">Produktname</label><input class="s-input" name="name" value={p.name} required /></div>
|
||||||
@@ -60,15 +78,35 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
|
|||||||
|
|
||||||
<div class="s-card s-card-pad">
|
<div class="s-card s-card-pad">
|
||||||
<div class="s-section-title" style="margin-bottom:12px">Medien</div>
|
<div class="s-section-title" style="margin-bottom:12px">Medien</div>
|
||||||
<div class="s-field"><label class="s-label">Karten-Bild (URL)</label><input class="s-input" name="cardImage" value={p.cardImage} /></div>
|
<div class="s-field"><label class="s-label">Karten-Bild</label>
|
||||||
<div class="s-field"><label class="s-label">Galerie-Bilder (eine URL pro Zeile)</label><textarea class="s-textarea" name="images">{(p.images || []).join('\n')}</textarea></div>
|
<div class="s-imgfield">
|
||||||
|
<img class="s-imgthumb" id="cardImageThumb" src={p.cardImage || ''} alt="" style={p.cardImage ? '' : 'display:none'} />
|
||||||
|
<input class="s-input" name="cardImage" id="cardImageInput" value={p.cardImage} placeholder="Bild-URL" />
|
||||||
|
<button type="button" class="s-btn s-btn-sm" data-pick-single="cardImageInput" data-thumb="cardImageThumb">📷 Wählen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="s-field"><label class="s-label">Galerie-Bilder (eine URL pro Zeile)</label>
|
||||||
|
<textarea class="s-textarea" name="images" id="imagesInput">{(p.images || []).join('\n')}</textarea>
|
||||||
|
<button type="button" class="s-btn s-btn-sm" data-pick-multi="imagesInput" style="margin-top:6px">📷 Aus Medien wählen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-card s-card-pad">
|
||||||
|
<div class="s-section-title" style="margin-bottom:6px">Varianten (Größe × Farbe …)</div>
|
||||||
|
<p class="s-help" style="margin:0 0 14px">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).</p>
|
||||||
|
<div class="s-vopts" id="voptList"></div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
|
||||||
|
<button type="button" class="s-btn s-btn-sm" id="voptAdd">+ Option hinzufügen</button>
|
||||||
|
<button type="button" class="s-btn s-btn-sm s-btn-primary" id="vgen">Varianten-Matrix erzeugen</button>
|
||||||
|
</div>
|
||||||
|
<div id="vmatrixWrap" style="margin-top:16px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="s-card s-card-pad">
|
<div class="s-card s-card-pad">
|
||||||
<div class="s-section-title" style="margin-bottom:12px">Eigenschaften</div>
|
<div class="s-section-title" style="margin-bottom:12px">Eigenschaften</div>
|
||||||
<div class="s-form-grid">
|
<div class="s-form-grid">
|
||||||
<div class="s-field"><label class="s-label">Material</label><input class="s-input" name="material" value={p.material} /></div>
|
<div class="s-field"><label class="s-label">Material</label><input class="s-input" name="material" value={p.material} /></div>
|
||||||
<div class="s-field"><label class="s-label">Varianten / Größen (Komma-getrennt)</label><input class="s-input" name="sizes" value={(p.sizes || []).join(', ')} /></div>
|
<div class="s-field"><label class="s-label">Einfache Varianten / Größen (Komma-getrennt)</label><input class="s-input" name="sizes" value={(p.sizes || []).join(', ')} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="s-field"><label class="s-label">Features (eine Zeile pro Punkt)</label><textarea class="s-textarea" name="features">{(p.features || []).join('\n')}</textarea></div>
|
<div class="s-field"><label class="s-label">Features (eine Zeile pro Punkt)</label><textarea class="s-textarea" name="features">{(p.features || []).join('\n')}</textarea></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +139,7 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
|
|||||||
<datalist id="catlist">{cats.map((c) => (<option value={c} />))}</datalist>
|
<datalist id="catlist">{cats.map((c) => (<option value={c} />))}</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="s-form-grid">
|
<div class="s-form-grid">
|
||||||
<div class="s-field"><label class="s-label">Bestand</label><input class="s-input" name="stock" type="number" value={p.stock ?? ''} placeholder="∞" /></div>
|
<div class="s-field"><label class="s-label">Bestand (ohne Varianten)</label><input class="s-input" name="stock" type="number" value={p.stock ?? ''} placeholder="∞" /></div>
|
||||||
<div class="s-field"><label class="s-label">Reihenfolge</label><input class="s-input" name="sort" type="number" value={p.sort} /></div>
|
<div class="s-field"><label class="s-label">Reihenfolge</label><input class="s-input" name="sort" type="number" value={p.sort} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="s-field"><label class="s-label">Badge</label><input class="s-input" name="badge" value={p.badge} placeholder="z. B. Neu, Set" /></div>
|
<div class="s-field"><label class="s-label">Badge</label><input class="s-input" name="badge" value={p.badge} placeholder="z. B. Neu, Set" /></div>
|
||||||
@@ -110,4 +148,127 @@ const priceStr = product ? (product.priceCents / 100).toFixed(2).replace('.', ',
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/media-picker.js" is:inline></script>
|
||||||
|
<script is:inline define:vars={{ variantJson }}>
|
||||||
|
(function () {
|
||||||
|
var D = JSON.parse(variantJson);
|
||||||
|
var options = Array.isArray(D.options) ? D.options.map(function (o) { return { name: o.name || '', values: (o.values || []).slice() }; }) : [];
|
||||||
|
var variants = Array.isArray(D.variants) ? D.variants.map(function (v) { return { options: v.options || {}, sku: v.sku || '', price_cents: v.price_cents, stock: v.stock, image: v.image || '', active: v.active === undefined ? true : !!v.active }; }) : [];
|
||||||
|
var base = D.basePriceCents || 0;
|
||||||
|
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||||
|
function sig(o){return JSON.stringify(Object.keys(o).sort().reduce(function(a,k){a[k]=o[k];return a;},{}));}
|
||||||
|
|
||||||
|
// ---- Optionen-Editor ----
|
||||||
|
var voptList = document.getElementById('voptList');
|
||||||
|
function renderOpts() {
|
||||||
|
voptList.innerHTML = '';
|
||||||
|
if (!options.length) { voptList.innerHTML = '<div class="s-help">Noch keine Optionen. Klicke „+ Option hinzufügen" (z. B. Größe, Farbe).</div>'; return; }
|
||||||
|
options.forEach(function (o, i) {
|
||||||
|
var row = document.createElement('div'); row.className = 's-vopt-row';
|
||||||
|
row.innerHTML =
|
||||||
|
'<input class="s-input" data-oname="' + i + '" value="' + esc(o.name) + '" placeholder="Optionsname (z. B. Größe)" />' +
|
||||||
|
'<input class="s-input" data-oval="' + i + '" value="' + esc((o.values || []).join(', ')) + '" placeholder="Werte, Komma-getrennt (S, M, L)" />' +
|
||||||
|
'<button type="button" class="s-btn s-btn-sm" data-orm="' + i + '" title="Entfernen">✕</button>';
|
||||||
|
voptList.appendChild(row);
|
||||||
|
});
|
||||||
|
voptList.querySelectorAll('[data-oname]').forEach(function (el) { el.addEventListener('input', function () { options[+el.getAttribute('data-oname')].name = el.value; }); });
|
||||||
|
voptList.querySelectorAll('[data-oval]').forEach(function (el) { el.addEventListener('input', function () { options[+el.getAttribute('data-oval')].values = el.value.split(',').map(function (x) { return x.trim(); }).filter(Boolean); }); });
|
||||||
|
voptList.querySelectorAll('[data-orm]').forEach(function (el) { el.addEventListener('click', function () { options.splice(+el.getAttribute('data-orm'), 1); renderOpts(); }); });
|
||||||
|
}
|
||||||
|
document.getElementById('voptAdd').addEventListener('click', function () { options.push({ name: '', values: [] }); renderOpts(); });
|
||||||
|
|
||||||
|
// ---- Kombinationen ----
|
||||||
|
function combos() {
|
||||||
|
var opts = options.map(function (o) { return { name: (o.name || '').trim(), values: (o.values || []).map(function (x) { return String(x).trim(); }).filter(Boolean) }; }).filter(function (o) { return o.name && o.values.length; });
|
||||||
|
if (!opts.length) return [];
|
||||||
|
var out = [{}];
|
||||||
|
opts.forEach(function (o) { var next = []; out.forEach(function (c) { o.values.forEach(function (val) { var n = {}; for (var k in c) n[k] = c[k]; n[o.name] = val; next.push(n); }); }); out = next; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerate() {
|
||||||
|
var prev = {}; variants.forEach(function (v) { prev[sig(v.options)] = v; });
|
||||||
|
var combos2 = combos();
|
||||||
|
variants = combos2.map(function (opts) {
|
||||||
|
var ex = prev[sig(opts)] || {};
|
||||||
|
return { options: opts, sku: ex.sku || '', price_cents: ex.price_cents, stock: ex.stock, image: ex.image || '', active: ex.active === undefined ? true : ex.active };
|
||||||
|
});
|
||||||
|
renderMatrix();
|
||||||
|
}
|
||||||
|
document.getElementById('vgen').addEventListener('click', regenerate);
|
||||||
|
|
||||||
|
// ---- Matrix-Tabelle ----
|
||||||
|
var wrap = document.getElementById('vmatrixWrap');
|
||||||
|
function renderMatrix() {
|
||||||
|
if (!variants.length) { wrap.innerHTML = '<div class="s-help">Keine Varianten. Definiere Optionen und klicke „Varianten-Matrix erzeugen".</div>'; return; }
|
||||||
|
var optNames = Object.keys(variants[0].options);
|
||||||
|
var head = optNames.map(function (n) { return '<th>' + esc(n) + '</th>'; }).join('') +
|
||||||
|
'<th>SKU</th><th class="num">Preis €</th><th class="num">Bestand</th><th>Bild</th><th>Aktiv</th>';
|
||||||
|
var rows = variants.map(function (v, i) {
|
||||||
|
var opts = optNames.map(function (n) { return '<td class="s-vbadge">' + esc(v.options[n]) + '</td>'; }).join('');
|
||||||
|
var priceStr = (v.price_cents == null || v.price_cents === '') ? '' : (v.price_cents / 100).toFixed(2).replace('.', ',');
|
||||||
|
return '<tr class="' + (v.active ? '' : 's-vrow-off') + '" data-vrow="' + i + '">' + opts +
|
||||||
|
'<td><input class="s-vin" data-vf="sku" data-vi="' + i + '" value="' + esc(v.sku) + '" placeholder="SKU"></td>' +
|
||||||
|
'<td class="num"><input class="s-vin" data-vf="price" data-vi="' + i + '" value="' + esc(priceStr) + '" placeholder="' + (base / 100).toFixed(2).replace('.', ',') + '"></td>' +
|
||||||
|
'<td class="num"><input class="s-vin" data-vf="stock" data-vi="' + i + '" value="' + (v.stock == null ? '' : esc(v.stock)) + '" placeholder="∞"></td>' +
|
||||||
|
'<td><img class="s-vimg" data-vimg="' + i + '" src="' + esc(v.image || '') + '"' + (v.image ? '' : ' style="display:none"') + '><button type="button" class="s-btn s-btn-sm" data-vpick="' + i + '">📷</button></td>' +
|
||||||
|
'<td><input type="checkbox" data-vact="' + i + '"' + (v.active ? ' checked' : '') + '></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
wrap.innerHTML = '<div class="s-table-wrap"><table class="s-vtable"><thead><tr>' + head + '</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||||||
|
|
||||||
|
wrap.querySelectorAll('[data-vf]').forEach(function (el) {
|
||||||
|
el.addEventListener('input', function () {
|
||||||
|
var i = +el.getAttribute('data-vi'), fld = el.getAttribute('data-vf');
|
||||||
|
if (fld === 'price') { var v = el.value.trim(); variants[i].price_cents = v === '' ? null : Math.round(parseFloat(v.replace(',', '.')) * 100); }
|
||||||
|
else if (fld === 'stock') { var sv = el.value.trim(); variants[i].stock = sv === '' ? null : parseInt(sv); }
|
||||||
|
else variants[i][fld] = el.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
wrap.querySelectorAll('[data-vact]').forEach(function (el) {
|
||||||
|
el.addEventListener('change', function () { var i = +el.getAttribute('data-vact'); variants[i].active = el.checked; el.closest('tr').classList.toggle('s-vrow-off', !el.checked); });
|
||||||
|
});
|
||||||
|
wrap.querySelectorAll('[data-vpick]').forEach(function (el) {
|
||||||
|
el.addEventListener('click', function () {
|
||||||
|
var i = +el.getAttribute('data-vpick');
|
||||||
|
if (!window.HDCMedia) return;
|
||||||
|
window.HDCMedia.pick({ multiple: false, onPick: function (url) { variants[i].image = url; renderMatrix(); } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Picker für Karten-/Galerie-Bilder ----
|
||||||
|
document.querySelectorAll('[data-pick-single]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var inp = document.getElementById(btn.getAttribute('data-pick-single'));
|
||||||
|
var thumb = btn.getAttribute('data-thumb') ? document.getElementById(btn.getAttribute('data-thumb')) : null;
|
||||||
|
if (!window.HDCMedia) return;
|
||||||
|
window.HDCMedia.pick({ multiple: false, onPick: function (url) { inp.value = url; if (thumb) { thumb.src = url; thumb.style.display = ''; } } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-pick-multi]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var inp = document.getElementById(btn.getAttribute('data-pick-multi'));
|
||||||
|
if (!window.HDCMedia) return;
|
||||||
|
window.HDCMedia.pick({ multiple: true, onPick: function (urls) {
|
||||||
|
var cur = inp.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
|
||||||
|
inp.value = cur.concat(urls).join('\n');
|
||||||
|
} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Submit: Hidden-Felder befüllen ----
|
||||||
|
document.getElementById('prodForm').addEventListener('submit', function () {
|
||||||
|
var cleanOpts = options.map(function (o) { return { name: (o.name || '').trim(), values: (o.values || []).map(function (x) { return String(x).trim(); }).filter(Boolean) }; }).filter(function (o) { return o.name && o.values.length; });
|
||||||
|
document.getElementById('optionsJson').value = JSON.stringify(cleanOpts);
|
||||||
|
// Varianten nur speichern, wenn Optionen definiert sind; sonst leere Liste (Produkt ohne Matrix).
|
||||||
|
document.getElementById('variantsJson').value = JSON.stringify(cleanOpts.length ? variants : []);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderOpts();
|
||||||
|
renderMatrix();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</Admin>
|
</Admin>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount,
|
createOrder, getSetting, validateDiscount, redeemDiscount, bestAutoDiscount,
|
||||||
shippingFor, taxFromGross, getProductBySlug, markOrderPaid, getOrderById, setOrderPayment,
|
shippingFor, taxFromGross, getProductBySlug, markOrderPaid, getOrderById, setOrderPayment,
|
||||||
feature, getCustomerByEmail, attachOrderToCustomer, markCartRecoveredByEmail,
|
feature, getCustomerByEmail, attachOrderToCustomer, markCartRecoveredByEmail, getVariantBySku, listVariants,
|
||||||
} from '../../lib/store.js';
|
} from '../../lib/store.js';
|
||||||
import { currentCustomer } from '../../lib/customer-auth.js';
|
import { currentCustomer } from '../../lib/customer-auth.js';
|
||||||
import { createPayment } from '../../lib/payments.js';
|
import { createPayment } from '../../lib/payments.js';
|
||||||
@@ -29,11 +29,24 @@ export async function POST({ request }) {
|
|||||||
|
|
||||||
const lineItems = items.map((i) => {
|
const lineItems = items.map((i) => {
|
||||||
const prod = i.slug ? getProductBySlug(i.slug) : null;
|
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 {
|
return {
|
||||||
slug: i.slug || (prod && prod.slug) || '',
|
slug: i.slug || (prod && prod.slug) || '',
|
||||||
name: i.name, size: i.size || '', qty: Math.max(1, parseInt(i.qty) || 1),
|
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,
|
mwst: prod ? Number(prod.mwst) || 0 : 19,
|
||||||
|
sku: sku || '', variant: options || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0);
|
const subtotal = lineItems.reduce((s, i) => s + i.priceCents * i.qty, 0);
|
||||||
|
|||||||
+80
-12
@@ -1,24 +1,92 @@
|
|||||||
import { addMedia } from '../../lib/store.js';
|
import { addMedia } from '../../lib/store.js';
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname, extname } from 'node:path';
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
function json(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } }); }
|
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 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 }) {
|
export async function POST({ request }) {
|
||||||
try {
|
try {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const file = form.get('file');
|
// Mehrfach-Upload: alle Felder "file"/"files" sammeln.
|
||||||
if (!file || typeof file === 'string') return json({ ok: false, error: 'Keine Datei' }, 400);
|
const files = [];
|
||||||
const buf = Buffer.from(await file.arrayBuffer());
|
for (const key of ['file', 'files']) {
|
||||||
const safe = (file.name || 'datei').replace(/[^a-zA-Z0-9._-]/g, '_');
|
for (const v of form.getAll(key)) { if (v && typeof v !== 'string') files.push(v); }
|
||||||
const fname = Date.now() + '-' + safe;
|
}
|
||||||
mkdirSync(UP_DIR, { recursive: true });
|
if (!files.length) return json({ ok: false, error: 'Keine Datei' }, 400);
|
||||||
writeFileSync(`${UP_DIR}/${fname}`, buf);
|
|
||||||
const url = '/uploads/' + fname;
|
const results = [];
|
||||||
addMedia({ filename: fname, url, mime: file.type || '', size: buf.length });
|
for (const f of files) {
|
||||||
return json({ ok: true, url });
|
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) {
|
} catch (e) {
|
||||||
return json({ ok: false, error: String(e && e.message || e) }, 500);
|
return json({ ok: false, error: String(e && e.message || e) }, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
---
|
---
|
||||||
import Base from '../../layouts/Base.astro';
|
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 { slug } = Astro.params;
|
||||||
const product = getProductBySlug(slug);
|
const product = getProductBySlug(slug);
|
||||||
if (!product) return Astro.redirect('/shop');
|
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 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 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 mwst = (product.mwst == null ? 19 : Number(product.mwst));
|
||||||
const basePrice = basePriceLabel(product.priceCents, product);
|
const basePrice = basePriceLabel(product.priceCents, product);
|
||||||
const wishlistOn = feature('feature_wishlist');
|
const wishlistOn = feature('feature_wishlist');
|
||||||
@@ -41,14 +43,26 @@ function starStr(n) { const full = Math.round(n); return '★★★★★'.slice
|
|||||||
</div>
|
</div>
|
||||||
{product.desc && <p class="pdp-desc">{product.desc}</p>}
|
{product.desc && <p class="pdp-desc">{product.desc}</p>}
|
||||||
|
|
||||||
{product.sizes && product.sizes.length > 0 && product.sizes[0] !== 'One Size' && (
|
{hasVariants ? (
|
||||||
|
<div id="variantPicker" data-variants={JSON.stringify({ options: product.options, variants: variants.map(v => ({ 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) => (
|
||||||
|
<div class="var-opt" data-opt={opt.name}>
|
||||||
|
<div class="s-section-title" style="margin-bottom:8px;font-weight:700;color:var(--ink)">{opt.name}</div>
|
||||||
|
<div class="size-row">
|
||||||
|
{opt.values.map((val) => (<button type="button" class="size-chip" data-optname={opt.name} data-optval={val}>{val}</button>))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div id="variantStatus" style="margin:6px 0 14px;font-size:13px;color:var(--faint)"></div>
|
||||||
|
</div>
|
||||||
|
) : (product.sizes && product.sizes.length > 0 && product.sizes[0] !== 'One Size' && (
|
||||||
<>
|
<>
|
||||||
<div class="s-section-title" style="margin-bottom:10px;font-weight:700;color:var(--ink)">Variante</div>
|
<div class="s-section-title" style="margin-bottom:10px;font-weight:700;color:var(--ink)">Variante</div>
|
||||||
<div class="size-row">
|
<div class="size-row">
|
||||||
{product.sizes.map((s, i) => (<button class={`size-chip ${i === 0 ? 'active' : ''}`} data-size={s}>{s}</button>))}
|
{product.sizes.map((s, i) => (<button class={`size-chip ${i === 0 ? 'active' : ''}`} data-size={s}>{s}</button>))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{reviewsOn && rsum.count > 0 && (
|
{reviewsOn && rsum.count > 0 && (
|
||||||
<a href="#reviews" class="pdp-rating" style="display:inline-flex;align-items:center;gap:8px;margin:-4px 0 14px;color:var(--accent);text-decoration:none">
|
<a href="#reviews" class="pdp-rating" style="display:inline-flex;align-items:center;gap:8px;margin:-4px 0 14px;color:var(--accent);text-decoration:none">
|
||||||
@@ -151,6 +165,53 @@ function starStr(n) { const full = Math.round(n); return '★★★★★'.slice
|
|||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function () {
|
(function () {
|
||||||
var main = document.getElementById('pdpMain');
|
var main = document.getElementById('pdpMain');
|
||||||
|
|
||||||
|
// --- Varianten (Größe × Farbe) ---
|
||||||
|
var vp = document.getElementById('variantPicker');
|
||||||
|
if (vp) {
|
||||||
|
var VD = JSON.parse(vp.getAttribute('data-variants') || '{}');
|
||||||
|
var optNames = (VD.options || []).map(function (o) { return o.name; });
|
||||||
|
var chosen = {};
|
||||||
|
var priceEl = document.querySelector('.pdp-price');
|
||||||
|
var statusEl = document.getElementById('variantStatus');
|
||||||
|
var addBtn = document.querySelector('[data-add-to-cart]');
|
||||||
|
function fmt(c) { try { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format((c || 0) / 100); } catch (e) { return ((c || 0) / 100).toFixed(2) + ' €'; } }
|
||||||
|
function findVariant() {
|
||||||
|
if (optNames.some(function (n) { return !chosen[n]; })) return null;
|
||||||
|
return (VD.variants || []).find(function (v) { return optNames.every(function (n) { return String(v.options[n]) === String(chosen[n]); }); }) || null;
|
||||||
|
}
|
||||||
|
function update() {
|
||||||
|
var v = findVariant();
|
||||||
|
var complete = optNames.every(function (n) { return !!chosen[n]; });
|
||||||
|
if (!complete) { statusEl.textContent = 'Bitte Variante wählen.'; statusEl.style.color = 'var(--faint)'; if (addBtn) addBtn.disabled = false; return; }
|
||||||
|
if (!v) { statusEl.textContent = 'Diese Kombination ist nicht verfügbar.'; statusEl.style.color = '#b3261e'; if (addBtn) { addBtn.disabled = true; } return; }
|
||||||
|
var price = (v.price_cents == null || v.price_cents === '') ? VD.basePriceCents : v.price_cents;
|
||||||
|
if (priceEl) priceEl.textContent = fmt(price);
|
||||||
|
if (v.image && main) { main.src = v.image; }
|
||||||
|
var sold = (v.stock != null && v.stock <= 0);
|
||||||
|
if (sold) { statusEl.textContent = 'Nicht lieferbar (ausverkauft).'; statusEl.style.color = '#b3261e'; if (addBtn) addBtn.disabled = true; }
|
||||||
|
else { statusEl.textContent = (v.stock != null ? v.stock + ' auf Lager' : 'Verfügbar'); statusEl.style.color = 'var(--accent)'; if (addBtn) addBtn.disabled = false; }
|
||||||
|
// dem Add-Button die Variante mitgeben
|
||||||
|
if (addBtn) {
|
||||||
|
var base = JSON.parse(addBtn.getAttribute('data-product') || '{}');
|
||||||
|
base.variant = { id: v.id, sku: v.sku, options: v.options };
|
||||||
|
base.priceCents = price;
|
||||||
|
base.image = v.image || base.image;
|
||||||
|
addBtn.setAttribute('data-product', JSON.stringify(base));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vp.querySelectorAll('[data-optname]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var name = btn.getAttribute('data-optname'), val = btn.getAttribute('data-optval');
|
||||||
|
chosen[name] = val;
|
||||||
|
vp.querySelectorAll('[data-optname="' + name + '"]').forEach(function (b) { b.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.pdp-thumbs img').forEach(function (t) {
|
document.querySelectorAll('.pdp-thumbs img').forEach(function (t) {
|
||||||
t.addEventListener('click', function () {
|
t.addEventListener('click', function () {
|
||||||
main.src = t.getAttribute('data-src');
|
main.src = t.getAttribute('data-src');
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const q = (url.searchParams.get('q') || '').trim();
|
|||||||
const wishlistOn = feature('feature_wishlist');
|
const wishlistOn = feature('feature_wishlist');
|
||||||
const results = q ? searchProducts(q) : [];
|
const results = q ? searchProducts(q) : [];
|
||||||
if (q) {
|
if (q) {
|
||||||
try { recordEvent({ type: 'product_view', path: '/suche', meta: { search: q, hits: results.length } }); } catch {}
|
try { recordEvent({ type: 'search', path: '/suche', meta: { q, results: results.length } }); } catch {}
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
<Base title={q ? `Suche: ${q}` : 'Suche'}>
|
<Base title={q ? `Suche: ${q}` : 'Suche'}>
|
||||||
|
|||||||
@@ -241,3 +241,58 @@
|
|||||||
|
|
||||||
/* v2.3 — Bewertungs-Zähler in der Nav */
|
/* v2.3 — Bewertungs-Zähler in der Nav */
|
||||||
.s-nav-badge{margin-left:auto;min-width:18px;height:18px;padding:0 5px;border-radius:999px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;line-height:1}
|
.s-nav-badge{margin-left:auto;min-width:18px;height:18px;padding:0 5px;border-radius:999px;background:var(--accent);color:#fff;font-size:11px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;line-height:1}
|
||||||
|
|
||||||
|
/* v2.4 — Medien-Picker-Modal + Varianten-Matrix */
|
||||||
|
.mp-overlay{position:fixed;inset:0;background:rgba(20,24,30,.46);backdrop-filter:blur(3px);display:none;align-items:center;justify-content:center;z-index:300;padding:24px}
|
||||||
|
.mp-overlay.open{display:flex;animation:fade .15s var(--s-ease)}
|
||||||
|
.mp-modal{width:min(920px,96vw);max-height:90vh;display:flex;flex-direction:column;background:var(--s-bg);border:1px solid var(--s-border);border-radius:16px;box-shadow:0 24px 70px rgba(0,0,0,.32);overflow:hidden;animation:pop .18s var(--s-ease)}
|
||||||
|
.mp-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--s-border);font-family:var(--s-display);font-size:16px;color:var(--s-ink)}
|
||||||
|
.mp-head-actions{display:flex;align-items:center;gap:8px}
|
||||||
|
.mp-upbtn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border:1px solid var(--accent);color:var(--accent-dark);border-radius:9px;font-size:13px;font-weight:600;cursor:pointer;background:var(--s-surface)}
|
||||||
|
.mp-upbtn:hover{background:var(--s-acc-ring)}
|
||||||
|
.mp-close{width:30px;height:30px;border:none;background:var(--s-sunken);border-radius:8px;cursor:pointer;color:var(--s-subtle);font-size:15px}
|
||||||
|
.mp-close:hover{background:var(--s-border);color:var(--s-ink)}
|
||||||
|
.mp-drop{margin:14px 18px 0;padding:12px;border:1px dashed var(--s-border-2);border-radius:10px;text-align:center;font-size:12.5px;color:var(--s-faint);transition:.15s}
|
||||||
|
.mp-drop.over{border-color:var(--accent);background:var(--s-acc-ring);color:var(--accent-dark)}
|
||||||
|
.mp-msg{margin:8px 18px 0;font-size:12.5px;min-height:0;color:var(--s-faint);display:none}
|
||||||
|
.mp-msg.show{display:block}
|
||||||
|
.mp-msg.ok{color:var(--s-green-t,#1a7f4b)}
|
||||||
|
.mp-msg.err{color:var(--s-red-t,#b3261e)}
|
||||||
|
.mp-grid{flex:1;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:13px;padding:16px 18px}
|
||||||
|
.mp-empty{grid-column:1/-1;text-align:center;color:var(--s-faint);padding:40px 10px;font-size:13.5px}
|
||||||
|
.mp-card{position:relative;border:1px solid var(--s-border);border-radius:11px;overflow:hidden;background:var(--s-surface);cursor:pointer;transition:.15s}
|
||||||
|
.mp-card:hover{box-shadow:var(--s-shadow-pop);transform:translateY(-2px)}
|
||||||
|
.mp-card.sel{border-color:var(--accent);box-shadow:0 0 0 2px var(--s-acc-ring)}
|
||||||
|
.mp-thumb{height:104px;background:var(--s-sunken)}
|
||||||
|
.mp-thumb img{width:100%;height:100%;object-fit:cover}
|
||||||
|
.mp-meta{padding:7px 9px;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.mp-name{font-size:11px;color:var(--s-ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.mp-size{font-size:10.5px;color:var(--s-faint)}
|
||||||
|
.mp-card-acts{position:absolute;top:6px;right:6px;display:flex;gap:4px;opacity:0;transition:.15s}
|
||||||
|
.mp-card:hover .mp-card-acts{opacity:1}
|
||||||
|
.mp-ico{width:26px;height:26px;border:none;border-radius:7px;background:rgba(255,255,255,.92);box-shadow:var(--s-shadow);cursor:pointer;font-size:12px;display:grid;place-items:center}
|
||||||
|
.mp-ico:hover{background:#fff}
|
||||||
|
.mp-del:hover{color:#b3261e}
|
||||||
|
.mp-foot{display:flex;align-items:center;gap:10px;padding:12px 18px;border-top:1px solid var(--s-border)}
|
||||||
|
.mp-sel{font-size:12.5px;color:var(--s-subtle);font-weight:600}
|
||||||
|
.mp-btn{padding:8px 16px;border:1px solid var(--s-border);border-radius:9px;background:var(--s-surface);font-size:13px;font-weight:600;cursor:pointer;color:var(--s-ink);font-family:inherit}
|
||||||
|
.mp-btn:hover{border-color:var(--s-border-2)}
|
||||||
|
.mp-primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||||
|
.mp-primary:hover{background:var(--accent-dark);border-color:var(--accent-dark)}
|
||||||
|
|
||||||
|
/* Bild-Feld mit Vorschau + Picker */
|
||||||
|
.s-imgfield{display:flex;gap:8px;align-items:center}
|
||||||
|
.s-imgfield .s-input{flex:1}
|
||||||
|
.s-imgthumb{width:42px;height:42px;border-radius:8px;object-fit:cover;background:var(--s-sunken);border:1px solid var(--s-border);flex:none}
|
||||||
|
|
||||||
|
/* Varianten-Matrix */
|
||||||
|
.s-vopts{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.s-vopt-row{display:grid;grid-template-columns:160px 1fr auto;gap:8px;align-items:center}
|
||||||
|
.s-vtable{width:100%;border-collapse:collapse;font-size:12.5px}
|
||||||
|
.s-vtable th,.s-vtable td{padding:7px 8px;border-bottom:1px solid var(--s-line-soft);text-align:left;vertical-align:middle}
|
||||||
|
.s-vtable th{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--s-faint);font-weight:700}
|
||||||
|
.s-vtable input.s-vin{width:100%;padding:6px 8px;border:1px solid var(--s-border);border-radius:7px;font-size:12.5px;font-family:inherit}
|
||||||
|
.s-vtable td.num input{text-align:right}
|
||||||
|
.s-vtable .s-vimg{width:34px;height:34px;border-radius:6px;object-fit:cover;background:var(--s-sunken);border:1px solid var(--s-border);cursor:pointer}
|
||||||
|
.s-vrow-off{opacity:.5}
|
||||||
|
.s-vbadge{font-size:11px;color:var(--s-subtle)}
|
||||||
|
|||||||
@@ -71,5 +71,53 @@ t('Sanitizer lässt normales Markup', () => assert.ok(/<strong>/.test(sanitizeHt
|
|||||||
t('recovered Karte nicht mehr fällig', () => { const due = store.dueAbandonedCarts(0); assert.ok(!due.some(c => c.email === 'cart@example.com')); });
|
t('recovered Karte nicht mehr fällig', () => { const due = store.dueAbandonedCarts(0); assert.ok(!due.some(c => c.email === 'cart@example.com')); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- v2.4: Varianten-Matrix ---
|
||||||
|
{
|
||||||
|
const combos = store.variantCombinations([{ name: 'Größe', values: ['S', 'M', 'L'] }, { name: 'Farbe', values: ['Blau', 'Rot'] }]);
|
||||||
|
t('variantCombinations: 3×2 = 6 Kombinationen', () => assert.strictEqual(combos.length, 6));
|
||||||
|
t('variantCombinations enthält {Größe:M,Farbe:Rot}', () => assert.ok(combos.some(c => c['Größe'] === 'M' && c['Farbe'] === 'Rot')));
|
||||||
|
t('variantCombinations: leere Optionen → []', () => assert.strictEqual(store.variantCombinations([]).length, 0));
|
||||||
|
t('variantCombinations: Option ohne Werte ignoriert', () => assert.strictEqual(store.variantCombinations([{ name: 'X', values: [] }]).length, 0));
|
||||||
|
|
||||||
|
const pid = store.createProduct({ name: 'Varianten-Shirt', priceCents: 2000, category: 'Test', mwst: 19, options: [{ name: 'Größe', values: ['S', 'M'] }] });
|
||||||
|
store.setProductVariants(pid, [
|
||||||
|
{ options: { 'Größe': 'S' }, sku: 'SH-S', price_cents: null, stock: 3, active: true },
|
||||||
|
{ options: { 'Größe': 'M' }, sku: 'SH-M', price_cents: 2500, stock: 0, active: true },
|
||||||
|
]);
|
||||||
|
const vs = store.listVariants(pid);
|
||||||
|
t('setProductVariants legt 2 Varianten an', () => assert.strictEqual(vs.length, 2));
|
||||||
|
t('Variante per SKU auflösbar', () => { const v = store.getVariantBySku('SH-M'); assert.ok(v && v.options['Größe'] === 'M' && v.price_cents === 2500); });
|
||||||
|
t('Variante mit price_cents=null = Override leer', () => { const v = store.getVariantBySku('SH-S'); assert.strictEqual(v.price_cents, null); });
|
||||||
|
store.setProductVariants(pid, [{ options: { 'Größe': 'S' }, sku: 'SH-S2', stock: 9, active: true }]);
|
||||||
|
t('setProductVariants ersetzt Matrix atomar (jetzt 1)', () => assert.strictEqual(store.listVariants(pid).length, 1));
|
||||||
|
t('Produkt ohne Optionen weiter normal nutzbar', () => { const np = store.createProduct({ name: 'Simpel', priceCents: 500 }); const p = store.getProductById(np); assert.ok(p && p.priceCents === 500 && Array.isArray(p.options) && p.options.length === 0); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v2.4: Analytics (Conversion / AOV / Wiederkaufrate) ---
|
||||||
|
{
|
||||||
|
// Drei pageviews (zwei Sessions), zwei Käufe.
|
||||||
|
store.recordEvent({ type: 'pageview', session: 'sess-a' });
|
||||||
|
store.recordEvent({ type: 'pageview', session: 'sess-b' });
|
||||||
|
store.recordEvent({ type: 'search', path: '/suche', meta: { q: 'wolle', results: 4 } });
|
||||||
|
store.recordEvent({ type: 'search', path: '/suche', meta: { q: 'wolle', results: 4 } });
|
||||||
|
store.recordEvent({ type: 'search', path: '/suche', meta: { q: 'gibtsnicht', results: 0 } });
|
||||||
|
const a = store.analyticsSummary(30);
|
||||||
|
t('analyticsSummary: AOV = Umsatz/Käufe', () => { if (a.purchases > 0) assert.strictEqual(Math.round(a.aov), Math.round(a.revenue / a.purchases)); else assert.strictEqual(a.aov, 0); });
|
||||||
|
t('analyticsSummary: Conversion in Prozent plausibel', () => assert.ok(a.conversion >= 0 && a.conversion <= 100));
|
||||||
|
t('analyticsSummary: repeatRate vorhanden (0–100)', () => assert.ok(a.repeatRate >= 0 && a.repeatRate <= 100));
|
||||||
|
t('analyticsSummary: Top-Suchbegriff „wolle" mit 2 Suchen', () => { const w = a.topSearches.find(x => x.term === 'wolle'); assert.ok(w && w.hits === 2); });
|
||||||
|
t('analyticsSummary: „gibtsnicht" als Null-Treffer markiert', () => { const z = a.topSearches.find(x => x.term === 'gibtsnicht'); assert.ok(z && z.zero === 1); });
|
||||||
|
t('analyticsSummary: stockWarnings ist Array', () => assert.ok(Array.isArray(a.stockWarnings)));
|
||||||
|
t('analyticsSummary: bestsellers ist Array', () => assert.ok(Array.isArray(a.bestsellers)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- v2.4: Medien (Alt-Text + Löschen) ---
|
||||||
|
{
|
||||||
|
const mid = store.addMedia({ filename: 'x.webp', url: '/uploads/x.webp', mime: 'image/webp', size: 1234, width: 800, height: 600 });
|
||||||
|
t('addMedia speichert Maße', () => { const m = store.getMediaById(mid); assert.ok(m && m.width === 800 && m.height === 600); });
|
||||||
|
t('updateMediaAlt setzt Alt-Text', () => { store.updateMediaAlt(mid, 'Ein Bild'); assert.strictEqual(store.getMediaById(mid).alt, 'Ein Bild'); });
|
||||||
|
t('deleteMedia entfernt Datensatz', () => { store.deleteMedia(mid); assert.strictEqual(store.getMediaById(mid), undefined); });
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed`);
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user