From 5e0fceab151d2a5719b4b59963d8f3474b77fa41 Mon Sep 17 00:00:00 2001 From: s4luorth Date: Sat, 7 Feb 2026 13:04:04 +0100 Subject: [PATCH] Initial commit --- .claude/settings.local.json | 23 + .vscode/sftp.json | 11 + Docker Backend/.dockerignore | 6 + Docker Backend/.env.example | 29 + Docker Backend/.gitignore | 7 + Docker Backend/DEPLOYMENT.md | 423 ++ Docker Backend/DEPLOYMENT_EINFACH.md | 319 + Docker Backend/Dockerfile | 31 + Docker Backend/HANDSCHRIFT_VARIATIONEN.md | 198 + Docker Backend/QUICKSTART.md | 169 + Docker Backend/README.md | 461 ++ Docker Backend/TILDA_API_BEISPIEL.md | 331 + Docker Backend/bruno-tests/1 Health Check.bru | 21 + .../2 Preview Batch - Single Letter.bru | 51 + .../3 Preview Batch - With Envelope.bru | 60 + ...4 Preview Batch - Custom Envelope Text.bru | 39 + .../bruno-tests/5 Order Finalize.bru | 39 + .../bruno-tests/6 Order Generate.bru | 96 + Docker Backend/bruno-tests/bruno.json | 5 + .../bruno-tests/environments/Local.bru | 5 + Docker Backend/deploy.sh | 252 + Docker Backend/docker-compose.yml | 37 + Docker Backend/fonts/alva.svg | 2224 +++++++ Docker Backend/fonts/ellie.svg | 2033 ++++++ Docker Backend/fonts/tilda.svg | 2319 +++++++ Docker Backend/generate-9-orders.js | 258 + Docker Backend/package-lock.json | 1624 +++++ Docker Backend/package.json | 31 + Docker Backend/prepare-deployment.js | 406 ++ .../src/api/controllers/order-controller.js | 414 ++ .../src/api/controllers/paypal-controller.js | 200 + .../src/api/controllers/preview-controller.js | 322 + Docker Backend/src/api/middleware/auth.js | 72 + .../src/api/middleware/error-handler.js | 13 + .../src/api/middleware/rate-limiter.js | 53 + .../src/api/middleware/request-logger.js | 14 + .../src/api/routes/health-routes.js | 34 + Docker Backend/src/api/routes/order-routes.js | 94 + .../src/api/routes/paypal-routes.js | 14 + .../src/api/routes/preview-routes.js | 47 + Docker Backend/src/config/index.js | 53 + Docker Backend/src/lib/page-layout.js | 122 + Docker Backend/src/lib/svg-font-engine.js | 212 + Docker Backend/src/lib/svg-generator.js | 213 + Docker Backend/src/server.js | 55 + .../src/services/placeholder-service.js | 283 + .../src/services/scriptalizer-service.js | 230 + Docker Backend/test-9-orders.json | 58 + Docker Backend/test-api.sh | 39 + Docker Backend/test-complete.json | 85 + Docker Backend/test-finalize.json | 4 + .../test-generate-with-envelopes.json | 36 + Docker Backend/test-order-direct.json | 52 + Docker Backend/test-request.json | 12 + Docker Backend/test-scriptalizer-direct.js | 127 + Docker Backend/test-scriptalizer.js | 198 + Docker Backend/test-tilda-preview.json | 12 + Docker Backend/test-variation.json | 12 + FRONTEND_BACKEND_ZUSAMMENFASSUNG.md | 476 ++ n8n-email-template.html | 268 + skrift-configurator/.vscode/sftp.json | 11 + skrift-configurator/BACKEND_INTEGRATION.md | 388 ++ skrift-configurator/README.md | 318 + .../assets/css/configurator.css | 1463 +++++ .../assets/js/configurator-api.js | 392 ++ .../assets/js/configurator-app.js | 160 + .../js/configurator-backend-integration.js | 584 ++ .../assets/js/configurator-preview-manager.js | 622 ++ .../assets/js/configurator-pricing.js | 1035 ++++ .../assets/js/configurator-state.js | 1030 ++++ .../assets/js/configurator-ui.js | 5446 +++++++++++++++++ .../assets/js/configurator-utils.js | 96 + .../assets/js/price-calculator.js | 1145 ++++ skrift-configurator/check-db.php | 110 + skrift-configurator/create-test-voucher.php | 61 + skrift-configurator/debug-vouchers.php | 72 + skrift-configurator/includes/admin-orders.php | 186 + .../includes/admin-settings.php | 943 +++ .../includes/admin-vouchers.php | 403 ++ skrift-configurator/includes/api-proxy.php | 351 ++ skrift-configurator/readme.txt | 0 skrift-configurator/skrift-konfigurator.php | 200 + 82 files changed, 30348 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .vscode/sftp.json create mode 100644 Docker Backend/.dockerignore create mode 100644 Docker Backend/.env.example create mode 100644 Docker Backend/.gitignore create mode 100644 Docker Backend/DEPLOYMENT.md create mode 100644 Docker Backend/DEPLOYMENT_EINFACH.md create mode 100644 Docker Backend/Dockerfile create mode 100644 Docker Backend/HANDSCHRIFT_VARIATIONEN.md create mode 100644 Docker Backend/QUICKSTART.md create mode 100644 Docker Backend/README.md create mode 100644 Docker Backend/TILDA_API_BEISPIEL.md create mode 100644 Docker Backend/bruno-tests/1 Health Check.bru create mode 100644 Docker Backend/bruno-tests/2 Preview Batch - Single Letter.bru create mode 100644 Docker Backend/bruno-tests/3 Preview Batch - With Envelope.bru create mode 100644 Docker Backend/bruno-tests/4 Preview Batch - Custom Envelope Text.bru create mode 100644 Docker Backend/bruno-tests/5 Order Finalize.bru create mode 100644 Docker Backend/bruno-tests/6 Order Generate.bru create mode 100644 Docker Backend/bruno-tests/bruno.json create mode 100644 Docker Backend/bruno-tests/environments/Local.bru create mode 100644 Docker Backend/deploy.sh create mode 100644 Docker Backend/docker-compose.yml create mode 100644 Docker Backend/fonts/alva.svg create mode 100644 Docker Backend/fonts/ellie.svg create mode 100644 Docker Backend/fonts/tilda.svg create mode 100644 Docker Backend/generate-9-orders.js create mode 100644 Docker Backend/package-lock.json create mode 100644 Docker Backend/package.json create mode 100644 Docker Backend/prepare-deployment.js create mode 100644 Docker Backend/src/api/controllers/order-controller.js create mode 100644 Docker Backend/src/api/controllers/paypal-controller.js create mode 100644 Docker Backend/src/api/controllers/preview-controller.js create mode 100644 Docker Backend/src/api/middleware/auth.js create mode 100644 Docker Backend/src/api/middleware/error-handler.js create mode 100644 Docker Backend/src/api/middleware/rate-limiter.js create mode 100644 Docker Backend/src/api/middleware/request-logger.js create mode 100644 Docker Backend/src/api/routes/health-routes.js create mode 100644 Docker Backend/src/api/routes/order-routes.js create mode 100644 Docker Backend/src/api/routes/paypal-routes.js create mode 100644 Docker Backend/src/api/routes/preview-routes.js create mode 100644 Docker Backend/src/config/index.js create mode 100644 Docker Backend/src/lib/page-layout.js create mode 100644 Docker Backend/src/lib/svg-font-engine.js create mode 100644 Docker Backend/src/lib/svg-generator.js create mode 100644 Docker Backend/src/server.js create mode 100644 Docker Backend/src/services/placeholder-service.js create mode 100644 Docker Backend/src/services/scriptalizer-service.js create mode 100644 Docker Backend/test-9-orders.json create mode 100644 Docker Backend/test-api.sh create mode 100644 Docker Backend/test-complete.json create mode 100644 Docker Backend/test-finalize.json create mode 100644 Docker Backend/test-generate-with-envelopes.json create mode 100644 Docker Backend/test-order-direct.json create mode 100644 Docker Backend/test-request.json create mode 100644 Docker Backend/test-scriptalizer-direct.js create mode 100644 Docker Backend/test-scriptalizer.js create mode 100644 Docker Backend/test-tilda-preview.json create mode 100644 Docker Backend/test-variation.json create mode 100644 FRONTEND_BACKEND_ZUSAMMENFASSUNG.md create mode 100644 n8n-email-template.html create mode 100644 skrift-configurator/.vscode/sftp.json create mode 100644 skrift-configurator/BACKEND_INTEGRATION.md create mode 100644 skrift-configurator/README.md create mode 100644 skrift-configurator/assets/css/configurator.css create mode 100644 skrift-configurator/assets/js/configurator-api.js create mode 100644 skrift-configurator/assets/js/configurator-app.js create mode 100644 skrift-configurator/assets/js/configurator-backend-integration.js create mode 100644 skrift-configurator/assets/js/configurator-preview-manager.js create mode 100644 skrift-configurator/assets/js/configurator-pricing.js create mode 100644 skrift-configurator/assets/js/configurator-state.js create mode 100644 skrift-configurator/assets/js/configurator-ui.js create mode 100644 skrift-configurator/assets/js/configurator-utils.js create mode 100644 skrift-configurator/assets/js/price-calculator.js create mode 100644 skrift-configurator/check-db.php create mode 100644 skrift-configurator/create-test-voucher.php create mode 100644 skrift-configurator/debug-vouchers.php create mode 100644 skrift-configurator/includes/admin-orders.php create mode 100644 skrift-configurator/includes/admin-settings.php create mode 100644 skrift-configurator/includes/admin-vouchers.php create mode 100644 skrift-configurator/includes/api-proxy.php create mode 100644 skrift-configurator/readme.txt create mode 100644 skrift-configurator/skrift-konfigurator.php diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b415865 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,23 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(npm install:*)", + "Bash(npm run test:separator:*)", + "Bash(node test-scriptalizer.js:*)", + "Bash(node:*)", + "Bash(curl:*)", + "Bash(python -m json.tool:*)", + "Bash(bash:*)", + "Bash(pkill:*)", + "Bash(tree:*)", + "Bash(wc:*)", + "Bash(dir:*)", + "Bash(taskkill:*)", + "Bash(rsync:*)", + "Bash(cat:*)", + "Bash(npm run dev:*)", + "Bash(findstr:*)" + ] + } +} diff --git a/.vscode/sftp.json b/.vscode/sftp.json new file mode 100644 index 0000000..4e644eb --- /dev/null +++ b/.vscode/sftp.json @@ -0,0 +1,11 @@ +{ + "name": "skrift", + "host": "ae975.netcup.net", + "protocol": "ftp", + "port": 21, + "username": "skriftp", + "remotePath": "/", + "uploadOnSave": true, + "useTempFile": false, + "openSsh": false +} diff --git a/Docker Backend/.dockerignore b/Docker Backend/.dockerignore new file mode 100644 index 0000000..d132698 --- /dev/null +++ b/Docker Backend/.dockerignore @@ -0,0 +1,6 @@ +node_modules; +npm - debug.log.env.git.gitignore; +README.md; +test - scriptalizer.js; +cache; +output; diff --git a/Docker Backend/.env.example b/Docker Backend/.env.example new file mode 100644 index 0000000..60264b9 --- /dev/null +++ b/Docker Backend/.env.example @@ -0,0 +1,29 @@ +# Environment Configuration Example +# Copy this file to .env and fill in your values + +# Node Environment +NODE_ENV=production + +# Scriptalizer API Configuration +SCRIPTALIZER_LICENSE_KEY=your-license-key-here +SCRIPTALIZER_ERR_FREQUENCY=0 + +# Preview Configuration +# No batch size limit - frontend can send unlimited letters +# Backend automatically splits into 25-letter batches for Scriptalizer API +# No cache expiration +# No rate limiting + +# Server Configuration +PORT=4000 + +# API Authentication (optional) +API_TOKEN=your-api-token-here + +# PayPal Configuration (for B2C payments) +# Get credentials from PayPal Developer Dashboard: +# https://developer.paypal.com/dashboard/applications/ +PAYPAL_CLIENT_ID=your-paypal-client-id +PAYPAL_CLIENT_SECRET=your-paypal-client-secret +# Options: 'sandbox' or 'live' +PAYPAL_ENVIRONMENT=sandbox diff --git a/Docker Backend/.gitignore b/Docker Backend/.gitignore new file mode 100644 index 0000000..ea865d3 --- /dev/null +++ b/Docker Backend/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env +cache/ +output/ +npm-debug.log +.DS_Store +*.log diff --git a/Docker Backend/DEPLOYMENT.md b/Docker Backend/DEPLOYMENT.md new file mode 100644 index 0000000..1d30b69 --- /dev/null +++ b/Docker Backend/DEPLOYMENT.md @@ -0,0 +1,423 @@ +# Deployment Anleitung - Skrift Backend + +## Übersicht + +Diese Anleitung zeigt, wie du das Skrift Backend auf deinen vServer deployst. + +## Voraussetzungen auf dem Server + +- Ubuntu/Debian Linux Server +- Docker und Docker Compose installiert +- Nginx Proxy Manager läuft bereits +- SSH-Zugang zum Server + +## Option 1: Docker Image über Docker Hub (EMPFOHLEN) + +### Schritt 1: Docker Image erstellen und pushen + +Auf deinem lokalen Rechner: + +```bash +# 1. Ins Backend-Verzeichnis wechseln +cd "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend" + +# 2. Docker Login (benötigt Docker Hub Account) +docker login + +# 3. Image bauen (ersetze 'deinusername' mit deinem Docker Hub Username) +docker build -t deinusername/skrift-backend:latest . + +# 4. Image zu Docker Hub pushen +docker push deinusername/skrift-backend:latest +``` + +### Schritt 2: Auf dem Server deployen + +Per SSH auf dem Server: + +```bash +# 1. Verzeichnis erstellen +mkdir -p /opt/skrift-backend +cd /opt/skrift-backend + +# 2. docker-compose.yml erstellen (siehe unten) +nano docker-compose.yml + +# 3. .env Datei erstellen +nano .env + +# 4. Fonts-Ordner erstellen und Fonts hochladen +mkdir fonts +# Fonts per SCP hochladen (siehe Schritt 3) + +# 5. Output-Verzeichnis erstellen +mkdir -p /var/skrift-output +chmod 755 /var/skrift-output + +# 6. Container starten +docker-compose up -d + +# 7. Logs prüfen +docker-compose logs -f +``` + +### Schritt 3: Fonts per SCP hochladen + +Auf deinem lokalen Rechner: + +```bash +# Fonts zum Server kopieren +scp "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend\fonts\*.svg" \ + root@dein-server.de:/opt/skrift-backend/fonts/ +``` + +### docker-compose.yml für Server + +```yaml +version: '3.8' + +services: + skrift-backend: + image: deinusername/skrift-backend:latest # Dein Docker Hub Image + container_name: skrift-backend + restart: unless-stopped + ports: + - "4000:4000" # Oder anderer Port + environment: + - NODE_ENV=production + - PORT=4000 + - SCRIPTALIZER_LICENSE_KEY=${SCRIPTALIZER_LICENSE_KEY} + - SCRIPTALIZER_ERR_FREQUENCY=0 + - BATCH_SIZE=30 + - CACHE_LIFETIME_HOURS=2 + - RATE_LIMIT_PER_MINUTE=2 + volumes: + - ./fonts:/app/fonts:ro + - skrift-cache:/app/cache + - /var/skrift-output:/app/output + networks: + - skrift-network + +volumes: + skrift-cache: + driver: local + +networks: + skrift-network: + driver: bridge +``` + +### .env Datei für Server + +```bash +# Scriptalizer API +SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66 +SCRIPTALIZER_ERR_FREQUENCY=0 + +# Preview Settings +BATCH_SIZE=30 +CACHE_LIFETIME_HOURS=2 +RATE_LIMIT_PER_MINUTE=2 + +# Environment +NODE_ENV=production +``` + +## Option 2: Direkt vom Server bauen + +### Schritt 1: Code auf Server übertragen + +```bash +# Auf lokalem Rechner: Komplettes Backend-Verzeichnis kopieren +scp -r "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend" \ + root@dein-server.de:/opt/skrift-backend/ + +# Oder mit rsync (besser): +rsync -avz --exclude 'node_modules' --exclude 'output' --exclude 'cache' \ + "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend/" \ + root@dein-server.de:/opt/skrift-backend/ +``` + +### Schritt 2: Auf Server bauen und starten + +```bash +# Per SSH auf dem Server +cd /opt/skrift-backend + +# Output-Verzeichnis erstellen +mkdir -p /var/skrift-output +chmod 755 /var/skrift-output + +# Container bauen und starten +docker-compose up -d --build + +# Logs prüfen +docker-compose logs -f +``` + +## Option 3: Mit GitHub/GitLab (BESTE PRAXIS für Produktion) + +### Schritt 1: Repository erstellen + +```bash +# Auf lokalem Rechner +cd "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend" + +git init +git add . +git commit -m "Initial commit" + +# GitHub/GitLab Repository erstellen, dann: +git remote add origin https://github.com/deinusername/skrift-backend.git +git push -u origin main +``` + +### Schritt 2: Auf Server clonen und deployen + +```bash +# Auf dem Server +cd /opt +git clone https://github.com/deinusername/skrift-backend.git +cd skrift-backend + +# .env erstellen (nicht in Git!) +nano .env + +# Output-Verzeichnis erstellen +mkdir -p /var/skrift-output +chmod 755 /var/skrift-output + +# Starten +docker-compose up -d --build + +# Bei Updates einfach: +git pull +docker-compose up -d --build +``` + +## Nginx Proxy Manager Konfiguration + +1. Im Nginx Proxy Manager eine neue "Proxy Host" erstellen: + - **Domain Names**: `backend.deine-domain.de` (oder Subdomain deiner Wahl) + - **Scheme**: `http` + - **Forward Hostname/IP**: `skrift-backend` (Container-Name) + - **Forward Port**: `4000` + - **Cache Assets**: aktivieren + - **Block Common Exploits**: aktivieren + - **Websockets Support**: deaktivieren + +2. SSL-Zertifikat: + - Tab "SSL" öffnen + - "Request a new SSL Certificate" auswählen + - "Force SSL" aktivieren + - Let's Encrypt Email eingeben + +3. Optional - Custom Nginx Configuration: + ```nginx + # Größere Request Body Size für große Texte + client_max_body_size 10M; + + # Timeouts für lange Scriptalizer-Calls + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + ``` + +## Nützliche Docker-Befehle + +```bash +# Container Status prüfen +docker-compose ps + +# Logs anzeigen +docker-compose logs -f + +# Container neu starten +docker-compose restart + +# Container stoppen +docker-compose down + +# Container stoppen und Volumes löschen +docker-compose down -v + +# In Container-Shell gehen +docker exec -it skrift-backend sh + +# Image neu bauen +docker-compose build --no-cache + +# Alte Images aufräumen +docker image prune -a +``` + +## Gesundheits-Check + +Nach dem Deployment: + +```bash +# Lokal vom Server +curl http://localhost:4000/health + +# Über Domain (nach Nginx Proxy Setup) +curl https://backend.deine-domain.de/health + +# Sollte zurückgeben: +# {"status":"ok","timestamp":"2026-01-03T..."} +``` + +## WordPress Integration + +In deinem WordPress Plugin (Frontend) die Backend-URL konfigurieren: + +```javascript +// WordPress Plugin Einstellungen +const BACKEND_URL = 'https://backend.deine-domain.de'; + +// API Calls +fetch(`${BACKEND_URL}/api/preview/batch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + sessionId: 'session-uuid', + letters: [...] + }) +}); +``` + +## N8N Integration + +N8N kann die generierten Dateien im `/var/skrift-output` Verzeichnis abholen: + +```bash +# N8N Workflow - File Trigger Node +/var/skrift-output/*/order-metadata.json +``` + +Oder per API: + +```bash +# Webhook in N8N, wenn Bestellung fertig ist +POST https://n8n.deine-domain.de/webhook/order-complete +{ + "orderNumber": "SK-2026-01-03-001", + "path": "/var/skrift-output/SK-2026-01-03-001" +} +``` + +## Troubleshooting + +### Container startet nicht +```bash +# Logs prüfen +docker-compose logs + +# Typische Probleme: +# - Fonts fehlen: Fonts-Ordner prüfen +# - Port 4000 belegt: Port in docker-compose.yml ändern +# - .env fehlt: .env Datei erstellen +``` + +### Fonts werden nicht gefunden +```bash +# In Container prüfen +docker exec -it skrift-backend ls -la /app/fonts + +# Sollte zeigen: +# tilda.svg +# alva.svg +# ellie.svg +``` + +### API antwortet nicht +```bash +# Nginx Proxy Logs prüfen +docker logs nginx-proxy-manager + +# Backend Logs prüfen +docker-compose logs skrift-backend + +# Firewall prüfen +sudo ufw status +sudo ufw allow 4000/tcp # Falls direkt ohne Proxy +``` + +### Durchgestrichene Wörter erscheinen +```bash +# SCRIPTALIZER_ERR_FREQUENCY in .env auf 0 setzen +echo "SCRIPTALIZER_ERR_FREQUENCY=0" >> .env + +# Container neu starten +docker-compose restart +``` + +## Monitoring + +### Einfaches Monitoring mit Healthcheck + +```bash +# Cronjob erstellen für Health-Check +crontab -e + +# Jede 5 Minuten prüfen +*/5 * * * * curl -f http://localhost:4000/health || systemctl restart skrift-backend +``` + +### Mit Uptime Kuma (empfohlen) + +1. Uptime Kuma installieren (auch als Docker Container) +2. HTTP(s) Monitor erstellen für `https://backend.deine-domain.de/health` +3. Alert bei Ausfall per E-Mail/Telegram + +## Backup + +```bash +# Fonts sichern (einmalig) +tar -czf skrift-fonts-backup.tar.gz /opt/skrift-backend/fonts + +# Output-Dateien sichern (täglich via Cronjob) +tar -czf skrift-output-$(date +%Y%m%d).tar.gz /var/skrift-output + +# Zu externem Speicher kopieren +rsync -avz /var/skrift-output/ backup-server:/backups/skrift/ +``` + +## Updates + +### Update über Docker Hub +```bash +# Neues Image bauen und pushen (lokal) +docker build -t deinusername/skrift-backend:latest . +docker push deinusername/skrift-backend:latest + +# Auf Server updaten +docker-compose pull +docker-compose up -d +``` + +### Update über Git +```bash +# Auf Server +cd /opt/skrift-backend +git pull +docker-compose up -d --build +``` + +## Sicherheit + +- `.env` Datei NIEMALS in Git committen +- Regelmäßig Updates: `docker-compose pull` +- Nginx Proxy Manager für SSL/TLS +- Firewall: Nur notwendige Ports öffnen +- Rate Limiting ist bereits im Backend implementiert +- Regelmäßige Backups der Output-Dateien + +## Support + +Bei Problemen: +1. Logs prüfen: `docker-compose logs -f` +2. Health-Endpoint testen: `curl http://localhost:4000/health` +3. Container Status: `docker-compose ps` +4. Ins Container-Shell: `docker exec -it skrift-backend sh` diff --git a/Docker Backend/DEPLOYMENT_EINFACH.md b/Docker Backend/DEPLOYMENT_EINFACH.md new file mode 100644 index 0000000..f3a7950 --- /dev/null +++ b/Docker Backend/DEPLOYMENT_EINFACH.md @@ -0,0 +1,319 @@ +# Einfaches Deployment OHNE Docker Hub + +## Warum diese Methode? + +- ✅ Kein Docker Hub Account nötig +- ✅ Keine Gefahr, dass andere dein Image sehen +- ✅ Schneller und einfacher +- ✅ Perfekt für einen einzelnen Server + +## Deployment-Prozess + +### Schritt 1: Backend zum Server kopieren + +**Option A: Mit SCP (Windows PowerShell oder Git Bash)** + +```bash +# Temporäres Archiv erstellen (ohne unnötige Dateien) +cd "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend" + +# Zip erstellen +tar -czf skrift-backend.tar.gz \ + --exclude='node_modules' \ + --exclude='output' \ + --exclude='cache' \ + --exclude='.git' \ + --exclude='*.md' \ + --exclude='bruno-tests' \ + --exclude='test-*.js' \ + --exclude='generate-*.js' \ + src/ fonts/ package.json package-lock.json Dockerfile docker-compose.yml .dockerignore + +# Zum Server kopieren +scp skrift-backend.tar.gz root@DEINE-SERVER-IP:/tmp/ + +# Auf Server entpacken +ssh root@DEINE-SERVER-IP << 'ENDSSH' + mkdir -p /opt/skrift-backend + cd /opt/skrift-backend + tar -xzf /tmp/skrift-backend.tar.gz + rm /tmp/skrift-backend.tar.gz +ENDSSH +``` + +**Option B: Mit RSYNC (empfohlen, wenn verfügbar)** + +```bash +# Direkt synchronisieren (nur geänderte Dateien) +rsync -avz --progress \ + --exclude='node_modules' \ + --exclude='output' \ + --exclude='cache' \ + --exclude='.git' \ + --exclude='*.md' \ + --exclude='bruno-tests' \ + --exclude='test-*.js' \ + --exclude='generate-*.js' \ + "/e/Dokumente/05_Skrift/Frontend_Backend_Konfigurator/Docker Backend/" \ + root@DEINE-SERVER-IP:/opt/skrift-backend/ +``` + +**Option C: Mit WinSCP (GUI)** + +1. WinSCP herunterladen und installieren +2. Verbindung zu deinem Server herstellen +3. Verzeichnis erstellen: `/opt/skrift-backend` +4. Diese Ordner hochladen: + - `src/` (kompletter Ordner) + - `fonts/` (kompletter Ordner) + - `package.json` + - `package-lock.json` + - `Dockerfile` + - `docker-compose.yml` + - `.dockerignore` + +### Schritt 2: Auf dem Server einrichten + +Per SSH auf den Server: + +```bash +ssh root@DEINE-SERVER-IP + +# Ins Backend-Verzeichnis +cd /opt/skrift-backend + +# .env Datei erstellen (WICHTIG!) +cat > .env << 'EOF' +SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66 +SCRIPTALIZER_ERR_FREQUENCY=0 +BATCH_SIZE=30 +CACHE_LIFETIME_HOURS=2 +RATE_LIMIT_PER_MINUTE=2 +NODE_ENV=production +PORT=4000 +EOF + +# Berechtigungen setzen +chmod 600 .env + +# Output-Verzeichnis für N8N erstellen +mkdir -p /var/skrift-output +chmod 755 /var/skrift-output + +# Docker Image bauen und Container starten +docker-compose up -d --build + +# Logs ansehen (Ctrl+C zum Beenden) +docker-compose logs -f +``` + +### Schritt 3: Testen + +```bash +# Health-Check auf dem Server +curl http://localhost:4000/health + +# Sollte zurückgeben: +# {"status":"ok","timestamp":"2026-01-03T..."} +``` + +### Schritt 4: Nginx Proxy Manager einrichten + +1. Öffne Nginx Proxy Manager (z.B. `http://dein-server.de:81`) +2. Login mit Admin-Credentials +3. "Proxy Hosts" → "Add Proxy Host" + +**Details Tab:** +- Domain Names: `backend.deine-domain.de` (oder `skrift-backend.deine-domain.de`) +- Scheme: `http` +- Forward Hostname/IP: `skrift-backend` (der Docker Container Name!) +- Forward Port: `4000` +- Cache Assets: ✓ aktivieren +- Block Common Exploits: ✓ aktivieren +- Websockets Support: nicht aktivieren + +**SSL Tab:** +- SSL Certificate: "Request a new SSL Certificate" +- Force SSL: ✓ aktivieren +- Email Address: deine-email@domain.de +- I Agree to the Terms: ✓ aktivieren + +4. "Save" klicken + +### Schritt 5: Finaler Test + +Von deinem lokalen Rechner: + +```bash +curl https://backend.deine-domain.de/health +``` + +## Updates durchführen + +Wenn du Code-Änderungen gemacht hast: + +```bash +# 1. Lokal: Neue Version zum Server kopieren +rsync -avz --progress \ + --exclude='node_modules' \ + --exclude='output' \ + --exclude='cache' \ + "/e/Dokumente/05_Skrift/Frontend_Backend_Konfigurator/Docker Backend/" \ + root@DEINE-SERVER-IP:/opt/skrift-backend/ + +# 2. Auf Server: Container neu bauen +ssh root@DEINE-SERVER-IP "cd /opt/skrift-backend && docker-compose up -d --build" + +# 3. Logs prüfen +ssh root@DEINE-SERVER-IP "cd /opt/skrift-backend && docker-compose logs -f" +``` + +## Automatisches Update-Script + +Erstelle eine Datei `update.sh` auf deinem lokalen Rechner: + +```bash +#!/bin/bash + +SERVER="root@DEINE-SERVER-IP" +REMOTE_PATH="/opt/skrift-backend" +LOCAL_PATH="/e/Dokumente/05_Skrift/Frontend_Backend_Konfigurator/Docker Backend" + +echo "Syncing files to server..." +rsync -avz --progress \ + --exclude='node_modules' \ + --exclude='output' \ + --exclude='cache' \ + --exclude='.git' \ + "$LOCAL_PATH/" \ + "$SERVER:$REMOTE_PATH/" + +echo "Rebuilding container on server..." +ssh $SERVER "cd $REMOTE_PATH && docker-compose up -d --build" + +echo "Done! Checking logs..." +ssh $SERVER "cd $REMOTE_PATH && docker-compose logs --tail=50" +``` + +Dann einfach ausführen: +```bash +bash update.sh +``` + +## Vorteile dieser Methode + +✅ **Privat**: Nur auf deinem Server, niemand sonst kann es sehen +✅ **Einfach**: Keine Docker Hub Registrierung nötig +✅ **Schnell**: Direkt auf dem Server gebaut +✅ **Sicher**: Keine Credentials in der Cloud +✅ **Flexibel**: Änderungen sofort deployen + +## Troubleshooting + +### Verbindung fehlgeschlagen +```bash +# SSH-Verbindung testen +ssh root@DEINE-SERVER-IP + +# Falls Fehler: SSH-Key einrichten +ssh-keygen -t rsa -b 4096 +ssh-copy-id root@DEINE-SERVER-IP +``` + +### Container startet nicht +```bash +# Logs prüfen +ssh root@DEINE-SERVER-IP "cd /opt/skrift-backend && docker-compose logs" + +# Häufige Probleme: +# - .env fehlt → Schritt 2 wiederholen +# - Fonts fehlen → fonts/ Ordner hochladen +# - Port 4000 belegt → docker-compose.yml anpassen +``` + +### Nginx Proxy erreicht Container nicht +```bash +# Prüfen ob Container im richtigen Netzwerk ist +ssh root@DEINE-SERVER-IP "docker network ls" +ssh root@DEINE-SERVER-IP "docker network inspect skrift-network" + +# Container Name prüfen +ssh root@DEINE-SERVER-IP "docker ps | grep skrift" + +# In Nginx Proxy Manager: Forward Hostname = "skrift-backend" (Container-Name) +``` + +### Build dauert zu lange +```bash +# Cache löschen und neu bauen +ssh root@DEINE-SERVER-IP "cd /opt/skrift-backend && docker-compose build --no-cache" +``` + +## Sicherheit + +- `.env` Datei wird **nur** auf dem Server erstellt (nicht hochgeladen) +- `.dockerignore` verhindert Upload von sensiblen Dateien +- Nur notwendige Ports werden geöffnet (4000 nur intern) +- Nginx Proxy Manager mit SSL-Verschlüsselung +- Rate Limiting ist bereits im Backend implementiert + +## Backup + +```bash +# Auf dem Server +# 1. Code-Backup +tar -czf /root/skrift-backend-backup-$(date +%Y%m%d).tar.gz /opt/skrift-backend + +# 2. Output-Dateien Backup +tar -czf /root/skrift-output-backup-$(date +%Y%m%d).tar.gz /var/skrift-output + +# 3. Zu lokalem Rechner herunterladen +scp root@DEINE-SERVER-IP:/root/skrift-*-backup-*.tar.gz ./backups/ +``` + +## Kompletter Workflow - Schritt für Schritt + +```bash +# === AUF LOKALEM RECHNER === + +# 1. Ins Verzeichnis wechseln +cd "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend" + +# 2. Zum Server kopieren +rsync -avz --exclude='node_modules' --exclude='output' --exclude='cache' ./ root@SERVER:/opt/skrift-backend/ + + +# === AUF DEM SERVER (per SSH) === + +# 3. Verbinden +ssh root@SERVER + +# 4. Setup +cd /opt/skrift-backend +cat > .env << 'EOF' +SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66 +SCRIPTALIZER_ERR_FREQUENCY=0 +BATCH_SIZE=30 +NODE_ENV=production +EOF + +mkdir -p /var/skrift-output +docker-compose up -d --build + +# 5. Testen +curl http://localhost:4000/health + + +# === IN NGINX PROXY MANAGER (Browser) === + +# 6. Proxy Host erstellen +# Domain: backend.deine-domain.de +# Forward to: skrift-backend:4000 +# SSL: Let's Encrypt aktivieren + + +# === FERTIG! === + +# Von überall testen: +curl https://backend.deine-domain.de/health +``` diff --git a/Docker Backend/Dockerfile b/Docker Backend/Dockerfile new file mode 100644 index 0000000..39d0737 --- /dev/null +++ b/Docker Backend/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-alpine + +# Install dependencies for better-sqlite3 if needed later +RUN apk add --no-cache python3 make g++ + +# Create app directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies (npm install instead of ci for flexibility) +RUN npm install --omit=dev + +# Copy application files +COPY . . + +# Create necessary directories +RUN mkdir -p /app/cache/previews \ + && mkdir -p /app/output \ + && mkdir -p /app/fonts + +# Expose port +EXPOSE 4000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:4000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" + +# Start application +CMD ["node", "src/server.js"] diff --git a/Docker Backend/HANDSCHRIFT_VARIATIONEN.md b/Docker Backend/HANDSCHRIFT_VARIATIONEN.md new file mode 100644 index 0000000..79af986 --- /dev/null +++ b/Docker Backend/HANDSCHRIFT_VARIATIONEN.md @@ -0,0 +1,198 @@ +# Handschrift-Variationen - Konfiguration + +## Aktuelle Einstellungen + +### 1. Scriptalizer Error Frequency +``` +SCRIPTALIZER_ERR_FREQUENCY=0 +``` +- **Bedeutung:** Keine durchgestrichenen Wörter oder Tippfehler +- **Ergebnis:** Saubere, fehlerfreie Handschrift + +--- + +### 2. Wortabstands-Variation: **15%** + +**Implementierung:** `src/lib/svg-font-engine.js` + +```javascript +// Leerzeichen mit 15% Variation +const baseSpace = fontSizePx * 0.4; +const variation = baseSpace * 0.15 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5); +const spacePx = baseSpace + variation; +``` + +**Effekt:** +- Basis-Wortabstand: 40% der Schriftgröße (≈10.4 px bei 26px Font) +- Variation: ±1.56 px (15% von 10.4px) +- Natürliche, ungleichmäßige Abstände zwischen Wörtern + +--- + +### 3. Wort-Rotation (Schräglage): **±5%** (≈±2.5°) + +**Implementierung:** `src/lib/svg-font-engine.js` + +```javascript +// Rotation zwischen -2.5° und +2.5° (±0.044 radians) +const rotationVariation = 0.044 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.022; +wordRotation = rotationVariation; + +// Matrix-Transformation mit Rotation +const cosR = Math.cos(wordRotation); +const sinR = Math.sin(wordRotation); +const transform = `matrix(${scale * cosR},${scale * sinR},${-scale * sinR},${-scale * cosR},${x},${baselineY})`; +``` + +**Effekt:** +- Jedes Wort bekommt eine individuelle, leichte Schräglage +- Rotation: -2.5° bis +2.5° (±0.044 Radians) +- Macht die Handschrift natürlicher und lebendiger +- Jedes Wort ist leicht unterschiedlich geneigt + +--- + +## Variation-Pattern + +### Sinuswellen-Basis +Beide Variationen verwenden Sinuswellen für natürliche, nicht-gleichförmige Muster: + +```javascript +Math.sin(wordIndex * 2.5) // Wortabstand (langsame Oszillation) +Math.sin(wordIndex * 3.7) // Rotation (schnellere Oszillation) +``` + +**Vorteil:** +- Keine zufälligen Sprünge +- Sanfte, natürliche Übergänge +- Reproduzierbar (gleicher Text → gleiche Variation) +- Kein Seed-Management nötig + +--- + +## Visuelle Beispiele + +### Ohne Variation (alt): +``` +Hallo Max Mustermann aus Berlin +``` +Alle Wörter perfekt gerade, gleiche Abstände + +### Mit Variation (neu): +``` +Hallo Max Mustermann aus Berlin + ↗ → ↘ → ↗ +``` +- Unterschiedliche Wortabstände (15% Variation) +- Leichte Schräglage pro Wort (±2.5°) + +--- + +## Technische Details + +### SVG Matrix-Transformation + +**Original (ohne Rotation):** +```xml + +``` + +**Neu (mit Rotation):** +```xml + +``` + +Die kleinen Werte in Position 2 und 3 der Matrix erzeugen die Rotation: +- Position 2 (b): `scale * sin(rotation)` ≈ 0.00004 +- Position 3 (c): `-scale * sin(rotation)` ≈ -0.00004 + +--- + +## Anpassung der Variationen + +### Wortabstand ändern + +**Datei:** `src/lib/svg-font-engine.js` (Zeile 187-189) + +```javascript +// Aktuell: 15% +const variation = baseSpace * 0.15 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5); + +// Mehr Variation (z.B. 25%): +const variation = baseSpace * 0.25 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5); + +// Weniger Variation (z.B. 8%): +const variation = baseSpace * 0.08 * (Math.sin(wordIndex * 2.5) * 0.5 + 0.5); +``` + +--- + +### Rotation (Schräglage) ändern + +**Datei:** `src/lib/svg-font-engine.js` (Zeile 200-202) + +```javascript +// Aktuell: ±2.5° (0.044 radians) +const rotationVariation = 0.044 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.022; + +// Stärker schräg (z.B. ±5°): +const rotationVariation = 0.087 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.0435; + +// Weniger schräg (z.B. ±1°): +const rotationVariation = 0.0175 * (Math.sin(wordIndex * 3.7) * 0.5 + 0.5) - 0.00875; + +// Keine Rotation (deaktivieren): +const rotationVariation = 0; +``` + +**Umrechnung Grad → Radians:** +``` +Radians = Grad × (π / 180) +±1° = ±0.0175 rad +±2.5° = ±0.044 rad +±5° = ±0.087 rad +``` + +--- + +## Test-Beispiel + +```bash +curl -X POST http://localhost:4000/api/preview/batch \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "variation-demo", + "letters": [{ + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Dies ist ein Test mit natürlicher Handschrift-Variation.\n\nDie Wörter haben unterschiedliche Abstände und eine leichte Schräglage.\n\nDas macht das Schriftbild authentischer!", + "placeholders": {} + }] + }' +``` + +--- + +## Performance + +**Impact:** +- Minimaler Overhead durch sin/cos Berechnungen +- Pro Zeichen: ~4 zusätzliche Operationen +- Bei 2000 Zeichen: ~0.5ms zusätzliche Verarbeitungszeit +- Vernachlässigbar im Vergleich zur Scriptalizer API (1-3 Sekunden) + +--- + +## Kompatibilität + +✅ **Alle SVG-Viewer** unterstützen Matrix-Transformationen +✅ **Alle Browser** (Chrome, Firefox, Safari, Edge) +✅ **Plotter-Software** verarbeitet transformierte Pfade korrekt +✅ **Keine Änderung der Pfad-Daten** (nur Transformation) + +--- + +**Version:** 1.1.0 +**Letzte Änderung:** 2026-01-02 +**Status:** Produktionsreif ✅ diff --git a/Docker Backend/QUICKSTART.md b/Docker Backend/QUICKSTART.md new file mode 100644 index 0000000..b74e07d --- /dev/null +++ b/Docker Backend/QUICKSTART.md @@ -0,0 +1,169 @@ +# Quick Start - Deployment in 5 Minuten + +## Schnellste Methode: Per SCP auf Server kopieren + +### 1. Server-Voraussetzungen prüfen + +SSH auf deinen Server und prüfe: + +```bash +# Docker installiert? +docker --version + +# Docker Compose installiert? +docker-compose --version + +# Falls nicht installiert: +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh +``` + +### 2. Backend auf Server kopieren + +Auf deinem **lokalen Windows-Rechner** (Git Bash oder WSL): + +```bash +# Ins Backend-Verzeichnis +cd "/e/Dokumente/05_Skrift/Frontend_Backend_Konfigurator/Docker Backend" + +# Zum Server kopieren (ersetze USER und SERVER) +rsync -avz \ + --exclude 'node_modules' \ + --exclude 'output' \ + --exclude 'cache' \ + --exclude '.git' \ + ./ root@dein-server.de:/opt/skrift-backend/ +``` + +**Oder mit SCP (wenn rsync nicht verfügbar):** + +```bash +# Windows PowerShell +scp -r "E:\Dokumente\05_Skrift\Frontend_Backend_Konfigurator\Docker Backend\*" root@dein-server.de:/opt/skrift-backend/ +``` + +### 3. Auf dem Server starten + +SSH auf den Server: + +```bash +ssh root@dein-server.de + +# Ins Backend-Verzeichnis +cd /opt/skrift-backend + +# .env Datei erstellen +cat > .env << 'EOF' +SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66 +SCRIPTALIZER_ERR_FREQUENCY=0 +BATCH_SIZE=30 +CACHE_LIFETIME_HOURS=2 +RATE_LIMIT_PER_MINUTE=2 +NODE_ENV=production +EOF + +# Output-Verzeichnis für N8N erstellen +mkdir -p /var/skrift-output +chmod 755 /var/skrift-output + +# Container bauen und starten +docker-compose up -d --build + +# Logs ansehen +docker-compose logs -f +``` + +### 4. Testen + +```bash +# Auf dem Server +curl http://localhost:4000/health + +# Sollte antworten mit: +# {"status":"ok","timestamp":"..."} +``` + +### 5. Nginx Proxy Manager einrichten + +1. Nginx Proxy Manager öffnen (z.B. http://dein-server.de:81) +2. "Proxy Hosts" → "Add Proxy Host" +3. Konfiguration: + - **Domain Names**: `backend.deine-domain.de` + - **Scheme**: `http` + - **Forward Hostname/IP**: `skrift-backend` + - **Forward Port**: `4000` + - **Cache Assets**: ✓ + - **Block Common Exploits**: ✓ + +4. Tab "SSL": + - **SSL Certificate**: "Request a new SSL Certificate" + - **Force SSL**: ✓ + - **Email**: deine@email.de + +5. Speichern + +### 6. Finaler Test + +```bash +# Von deinem lokalen Rechner +curl https://backend.deine-domain.de/health +``` + +## FERTIG! + +Dein Backend läuft jetzt auf: `https://backend.deine-domain.de` + +### WordPress Integration + +In deinem WordPress Plugin die Backend-URL eintragen: + +```php +define('SKRIFT_BACKEND_URL', 'https://backend.deine-domain.de'); +``` + +## Wichtige Befehle + +```bash +# Container Status +docker-compose ps + +# Logs ansehen +docker-compose logs -f + +# Container neu starten +docker-compose restart + +# Container stoppen +docker-compose down + +# Update nach Code-Änderungen +docker-compose up -d --build +``` + +## Problemlösung + +### Container startet nicht +```bash +docker-compose logs +# Häufig: Fonts fehlen oder .env nicht korrekt +``` + +### Port 4000 schon belegt +```bash +# In docker-compose.yml ändern: +ports: + - "4001:4000" # Anderen externen Port verwenden +``` + +### Keine Verbindung von außen +```bash +# Firewall prüfen +sudo ufw status +sudo ufw allow 4000/tcp +``` + +## Nächste Schritte + +- Siehe [DEPLOYMENT.md](./DEPLOYMENT.md) für Details +- Siehe [README.md](./README.md) für API-Dokumentation +- N8N Workflow einrichten für automatische Plotter-Übertragung diff --git a/Docker Backend/README.md b/Docker Backend/README.md new file mode 100644 index 0000000..38ac946 --- /dev/null +++ b/Docker Backend/README.md @@ -0,0 +1,461 @@ +# Skrift Backend - Handwritten Document Generator + +Docker-basiertes Backend für die Generierung von handschriftlichen Dokumenten (Briefe, Postkarten, Umschläge) mit SVG-Output. + +## Features + +- **Preview-System**: Batch-Generierung von Vorschauen mit Caching (30 Briefe pro Batch) +- **Scriptalizer Integration**: Nutzt externe API für natürliche Handschrift-Variationen +- **SVG-Generierung**: Eigene Font-Engine für hochqualitative SVG-Ausgabe +- **Multi-Format Support**: A4, A6 (Hoch-/Querformat), C6, DIN Lang Umschläge +- **Platzhalter-System**: Automatische Ersetzung von `[[Platzhalter]]` mit CSV-Export +- **Rate Limiting**: Schutz vor API-Spam (konfigurierbar) +- **Docker-Ready**: Vollständig containerisiert mit docker-compose + +## Quick Start + +### 1. Konfiguration + +```bash +cp .env.example .env +# .env bearbeiten und Scriptalizer License Key eintragen +``` + +### 2. Lokal testen (ohne Docker) + +```bash +npm install +npm start +``` + +Server läuft auf: `http://localhost:4000` + +### 3. Mit Docker deployen + +```bash +docker-compose up -d +``` + +## API-Endpunkte + +### Health Check + +``` +GET /health +``` + +**Response:** +```json +{ + "status": "healthy", + "timestamp": "2026-01-15T12:00:00Z", + "uptime": 12345, + "scriptalizer": "configured", + "storage": { + "cache": true, + "output": true + } +} +``` + +--- + +### Preview Batch Generierung + +``` +POST /api/preview/batch +``` + +**Request Body:** +```json +{ + "sessionId": "uuid-abc-123", + "batchIndex": 0, + "forceRegenerate": false, + "config": { + "font": "tilda", + "letters": [ + { + "index": 0, + "format": "a4", + "text": "Hallo [[Vorname]], dein Code ist [[Gutscheincode]]...", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Strasse": "Hauptstr. 1", + "PLZ": "10115", + "Ort": "Berlin", + "Gutscheincode": "SAVE20" + } + } + ], + "envelopes": [ + { + "index": 0, + "format": "c6", + "type": "recipient", + "data": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Strasse": "Hauptstr. 1", + "PLZ": "10115", + "Ort": "Berlin" + } + } + ] + } +} +``` + +**Response:** +```json +{ + "sessionId": "uuid-abc-123", + "files": [ + { + "type": "letter", + "index": 0, + "url": "/api/preview/uuid-abc-123/letter_000.svg" + }, + { + "type": "envelope", + "index": 0, + "url": "/api/preview/uuid-abc-123/envelope_000.svg" + } + ], + "csvUrl": "/api/preview/uuid-abc-123/platzhalter.csv", + "expiresAt": "2026-01-15T14:00:00Z" +} +``` + +**Rate Limit:** 2 Requests/Minute pro sessionId + +--- + +### Preview-Datei abrufen + +``` +GET /api/preview/:sessionId/:filename +``` + +**Beispiel:** +``` +GET /api/preview/uuid-abc-123/letter_000.svg +``` + +**Response:** SVG-Datei (Content-Type: image/svg+xml) + +--- + +### Bestellung finalisieren (aus Cache) + +``` +POST /api/order/finalize +``` + +**Request Body:** +```json +{ + "sessionId": "uuid-abc-123", + "orderNumber": "SK-2026-01-15-001" +} +``` + +**Response:** +```json +{ + "orderNumber": "SK-2026-01-15-001", + "outputPath": "/app/output/SK-2026-01-15-001", + "files": { + "letters": 100, + "envelopes": 100, + "csv": "platzhalter.csv" + }, + "timestamp": "2026-01-15T12:30:00Z" +} +``` + +--- + +### Bestellung neu generieren (ohne Cache) + +``` +POST /api/order/generate +``` + +**Request Body:** +```json +{ + "orderNumber": "SK-2026-01-15-002", + "config": { + "font": "tilda", + "letters": [...], + "envelopes": [...] + } +} +``` + +**Response:** Gleich wie `/api/order/finalize` + +--- + +## Formate + +### Schriftstücke (Letters) +- `a4` - A4 Hochformat (210 × 297 mm) +- `a6p` - A6 Hochformat (105 × 148 mm) +- `a6l` - A6 Querformat (148 × 105 mm) + +### Umschläge (Envelopes) +- `c6` - C6 Umschlag (162 × 114 mm) +- `din_lang` - DIN Lang Umschlag (220 × 110 mm) + +### Fonts +- `tilda` - PremiumUltra79 +- `alva` - PremiumUltra23 +- `ellie` - PremiumUltra39 + +--- + +## Umschlag-Typen + +### Empfänger-Adresse (type: "recipient") +Adresse wird **unten links** positioniert (kein Sichtfenster). + +```json +{ + "type": "recipient", + "data": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Strasse": "Hauptstr. 1", + "PLZ": "10115", + "Ort": "Berlin" + } +} +``` + +### Individueller Text (type: "custom") +Text wird **mittig zentriert** positioniert. Max. 150 Zeichen. + +```json +{ + "type": "custom", + "data": { + "customText": "Für meine großartige Freundin Caro" + } +} +``` + +--- + +## Verzeichnisstruktur + +``` +/app/ +├── cache/ +│ └── previews/ +│ └── {sessionId}/ +│ ├── letter_000.svg +│ ├── envelope_000.svg +│ ├── platzhalter.csv +│ └── metadata.json +│ +├── output/ +│ └── {orderNumber}/ +│ ├── schriftstuecke/ +│ │ ├── brief_000.svg +│ │ └── ... +│ ├── umschlaege/ +│ │ ├── umschlag_000.svg +│ │ └── ... +│ ├── platzhalter.csv +│ └── order-metadata.json +│ +└── fonts/ + ├── tilda.svg + ├── alva.svg + └── ellie.svg +``` + +--- + +## Umgebungsvariablen + +```bash +# Node Environment +NODE_ENV=production + +# Scriptalizer API +SCRIPTALIZER_LICENSE_KEY=your-key-here +SCRIPTALIZER_ERR_FREQUENCY=10 + +# Preview System +BATCH_SIZE=30 +CACHE_LIFETIME_HOURS=2 +RATE_LIMIT_PER_MINUTE=2 + +# Server +PORT=4000 +CORS_ORIGIN=* +``` + +--- + +## Deployment + +### Auf Server (mit Docker) + +```bash +# .env Datei erstellen mit production values +docker-compose up -d + +# Logs ansehen +docker-compose logs -f + +# Stoppen +docker-compose down +``` + +### Nginx Proxy Manager Setup + +1. Proxy Host erstellen +2. Domain: `api.skrift.de` (oder deine Domain) +3. Forward Hostname/IP: `localhost` +4. Forward Port: `4000` +5. SSL Zertifikat über NPM erstellen + +--- + +## Entwicklung + +### Lokales Testen + +```bash +npm run dev # Mit nodemon +``` + +### Scriptalizer Separator Test + +```bash +npm run test:separator +``` + +### Logs + +```bash +# Docker logs +docker-compose logs -f skrift-backend + +# Lokale logs +# Output in console +``` + +--- + +## Integration mit N8N + +N8N kann direkt auf den `/app/output/{orderNumber}/` Ordner zugreifen: + +```javascript +// N8N Workflow (Beispiel) +const fs = require('fs'); +const orderPath = '/var/skrift-output/SK-2026-01-15-001'; + +// Lese alle SVGs +const letters = fs.readdirSync(`${orderPath}/schriftstuecke`); + +// Sende an Plotter +for (const file of letters) { + await sendToPlotter(`${orderPath}/schriftstuecke/${file}`); +} +``` + +--- + +## Fehlerbehandlung + +### HTTP Status Codes + +- `200` - Success +- `400` - Bad Request (z.B. ungültige Parameter) +- `404` - Not Found (z.B. Session nicht gefunden) +- `410` - Gone (z.B. Cache abgelaufen) +- `429` - Too Many Requests (Rate Limit) +- `500` - Internal Server Error +- `503` - Service Unavailable (z.B. Scriptalizer down) + +### Typische Fehler + +**Rate Limit überschritten:** +```json +{ + "error": "Zu viele Vorschau-Anfragen. Bitte warten Sie.", + "retryAfter": 45, + "message": "Limit: 2 Anfragen pro Minute" +} +``` + +**Scriptalizer Fehler:** +```json +{ + "error": "Scriptalizer request failed: timeout" +} +``` + +**Cache abgelaufen:** +```json +{ + "error": "Preview-Session abgelaufen. Bitte neu generieren." +} +``` + +--- + +## Limits + +- **Scriptalizer API**: 10.000 Calls/Tag +- **Batch Size**: 30 Briefe pro Request +- **Input Size**: 48KB pro Scriptalizer Call +- **Rate Limit**: 2 Preview-Requests/Minute +- **Cache Lifetime**: 2 Stunden + +--- + +## Troubleshooting + +### Fonts nicht gefunden + +```bash +# Fonts kopieren +cp /path/to/fonts/*.svg ./fonts/ +``` + +### Scriptalizer API Fehler + +```bash +# License Key prüfen +cat .env | grep SCRIPTALIZER_LICENSE_KEY + +# Test-Script ausführen +npm run test:separator +``` + +### Permissions Fehler + +```bash +# Cache/Output Ordner Permissions +chmod -R 755 cache output +``` + +--- + +## Weitere Infos + +- **Scriptalizer API**: [www.scriptalizer.co.uk](https://www.scriptalizer.co.uk) +- **Support**: Siehe Issues in Repository + +--- + +**Version**: 1.0.0 +**Last Updated**: 2026-01-01 diff --git a/Docker Backend/TILDA_API_BEISPIEL.md b/Docker Backend/TILDA_API_BEISPIEL.md new file mode 100644 index 0000000..5fd0239 --- /dev/null +++ b/Docker Backend/TILDA_API_BEISPIEL.md @@ -0,0 +1,331 @@ +# Tilda Font API-Aufruf Beispiel + +## Konfiguration +- **Error Frequency:** 0 (keine durchgestrichenen Wörter) +- **Wortabstand-Variation:** ±5% für natürlicheres Schriftbild +- **Font:** Tilda (Scriptalizer: PremiumUltra79) + +--- + +## 1. Preview-Generierung (empfohlen) + +### Endpoint +``` +POST http://localhost:4000/api/preview/batch +``` + +### Request Headers +``` +Content-Type: application/json +``` + +### Request Body +```json +{ + "sessionId": "meine-session-12345", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team", + "placeholders": {} + } + ] +} +``` + +### cURL Beispiel +```bash +curl -X POST http://localhost:4000/api/preview/batch \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "meine-session-12345", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team", + "placeholders": {} + } + ] + }' +``` + +### Response +```json +{ + "sessionId": "meine-session-12345", + "files": [ + { + "index": 0, + "filename": "letter_000.svg", + "url": "/api/preview/meine-session-12345/letter_000.svg" + } + ], + "csvUrl": "/api/preview/meine-session-12345/placeholders.csv" +} +``` + +### Preview SVG abrufen +```bash +curl http://localhost:4000/api/preview/meine-session-12345/letter_000.svg -o preview.svg +``` + +--- + +## 2. Mit Platzhaltern + +### Request Body +```json +{ + "sessionId": "platzhalter-test", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung [[Bestellnummer]].\n\nIhr persönlicher Gutscheincode: [[Gutscheincode]]\nGültig bis: [[Ablaufdatum]]\n\nMit freundlichen Grüßen", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Bestellnummer": "SK-2026-001", + "Gutscheincode": "SAVE20", + "Ablaufdatum": "31.12.2026" + } + } + ] +} +``` + +--- + +## 3. Mehrere Briefe (Batch) + +### Request Body +```json +{ + "sessionId": "batch-test", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann" + } + }, + { + "index": 1, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!", + "placeholders": { + "Vorname": "Anna", + "Nachname": "Schmidt" + } + }, + { + "index": 2, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!", + "placeholders": { + "Vorname": "Thomas", + "Nachname": "Müller" + } + } + ] +} +``` + +**Hinweis:** Bis zu 30 Briefe pro Batch möglich (konfigurierbar) + +--- + +## 4. Bestellung finalisieren + +Nach der Vorschau kann die Bestellung finalisiert werden: + +### Endpoint +``` +POST http://localhost:4000/api/order/finalize +``` + +### Request Body +```json +{ + "sessionId": "meine-session-12345", + "orderNumber": "SK-2026-01-02-001" +} +``` + +### cURL Beispiel +```bash +curl -X POST http://localhost:4000/api/order/finalize \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "meine-session-12345", + "orderNumber": "SK-2026-01-02-001" + }' +``` + +### Response +```json +{ + "orderNumber": "SK-2026-01-02-001", + "outputPath": "/app/output/SK-2026-01-02-001", + "files": [ + "letter_000.svg", + "placeholders.csv" + ], + "timestamp": "2026-01-02T09:11:00.000Z" +} +``` + +--- + +## 5. Direkte Bestellungs-Generierung (ohne Preview) + +### Endpoint +``` +POST http://localhost:4000/api/order/generate +``` + +### Request Body +```json +{ + "orderNumber": "SK-2026-01-02-002", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir den Eingang Ihrer Bestellung.\n\nMit freundlichen Grüßen", + "placeholders": {} + } + ] +} +``` + +### cURL Beispiel +```bash +curl -X POST http://localhost:4000/api/order/generate \ + -H "Content-Type: application/json" \ + -d '{ + "orderNumber": "SK-2026-01-02-002", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir den Eingang Ihrer Bestellung.\n\nMit freundlichen Grüßen", + "placeholders": {} + } + ] + }' +``` + +--- + +## Verfügbare Formate + +- **a4**: A4 Hochformat (210 × 297 mm) +- **a6p**: A6 Hochformat (105 × 148 mm) +- **a6l**: A6 Querformat (148 × 105 mm) + +--- + +## Verfügbare Fonts + +- **tilda**: Scriptalizer PremiumUltra79 +- **alva**: Scriptalizer PremiumUltra23 +- **ellie**: Scriptalizer PremiumUltra39 + +--- + +## Features + +✅ **Keine Fehler:** Error Frequency = 0 (keine durchgestrichenen Wörter) +✅ **Wortabstand-Variation:** ±5% natürliche Variation für realistischeres Schriftbild +✅ **Platzhalter:** Automatische Ersetzung von [[Platzhalter]] +✅ **CSV-Export:** Automatische Generierung der Platzhalter-Tabelle +✅ **Batch-Verarbeitung:** Bis zu 30 Briefe pro Request +✅ **Preview-Caching:** 2 Stunden Cache für schnellere Finalisierung +✅ **Rate Limiting:** 2 Requests/Minute Schutz + +--- + +## Output + +Die generierten SVG-Dateien befinden sich in: +- **Preview:** `/app/cache/previews/{sessionId}/` +- **Finale Bestellung:** `/app/output/{orderNumber}/` + +Struktur: +``` +output/SK-2026-01-02-001/ +├── letter_000.svg +├── letter_001.svg +├── placeholders.csv +└── order-metadata.json +``` + +--- + +## Beispiel Output + +**letter_000.svg:** +```xml + + + + + +``` + +Dateigröße: ~100-120 KB pro A4-Seite + +--- + +## WordPress Integration Beispiel + +```javascript +// Im WordPress Plugin +const response = await fetch('https://api.skrift.de/api/preview/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: generateUUID(), + letters: [ + { + index: 0, + format: 'a4', + font: 'tilda', + text: brieftext, + placeholders: platzhalterDaten + } + ] + }) +}); + +const data = await response.json(); +// Zeige Preview: data.files[0].url +``` + +--- + +**Stand:** 2026-01-02 +**Version:** 1.0.0 diff --git a/Docker Backend/bruno-tests/1 Health Check.bru b/Docker Backend/bruno-tests/1 Health Check.bru new file mode 100644 index 0000000..c9303b8 --- /dev/null +++ b/Docker Backend/bruno-tests/1 Health Check.bru @@ -0,0 +1,21 @@ +meta { + name: 1 Health Check + type: http + seq: 1 +} + +get { + url: {{baseUrl}}/health + body: none + auth: none +} + +tests { + test("Status is 200", function() { + expect(res.status).to.equal(200); + }); + + test("Scriptalizer is configured", function() { + expect(res.body.scriptalizer).to.equal("configured"); + }); +} diff --git a/Docker Backend/bruno-tests/2 Preview Batch - Single Letter.bru b/Docker Backend/bruno-tests/2 Preview Batch - Single Letter.bru new file mode 100644 index 0000000..f5f44b9 --- /dev/null +++ b/Docker Backend/bruno-tests/2 Preview Batch - Single Letter.bru @@ -0,0 +1,51 @@ +meta { + name: 2 Preview Batch - Single Letter + type: http + seq: 2 +} + +post { + url: {{baseUrl}}/api/preview/batch + body: json + auth: none +} + +body:json { + { + "sessionId": "{{sessionId}}", + "batchIndex": 0, + "forceRegenerate": false, + "config": { + "font": "tilda", + "letters": [ + { + "index": 0, + "format": "a4", + "text": "Hallo [[Vorname]] [[Nachname]],\n\ndein persönlicher Gutscheincode lautet: [[Gutscheincode]]\n\nEr ist gültig bis zum [[Ablaufdatum]].\n\nViele Grüße,\nDein Skrift-Team", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Gutscheincode": "SAVE20", + "Ablaufdatum": "31.12.2026" + } + } + ], + "envelopes": [] + } + } +} + +tests { + test("Status is 200", function() { + expect(res.status).to.equal(200); + }); + + test("Returns session ID", function() { + expect(res.body.sessionId).to.be.a("string"); + }); + + test("Returns files array", function() { + expect(res.body.files).to.be.an("array"); + expect(res.body.files.length).to.be.greaterThan(0); + }); +} diff --git a/Docker Backend/bruno-tests/3 Preview Batch - With Envelope.bru b/Docker Backend/bruno-tests/3 Preview Batch - With Envelope.bru new file mode 100644 index 0000000..e334b3b --- /dev/null +++ b/Docker Backend/bruno-tests/3 Preview Batch - With Envelope.bru @@ -0,0 +1,60 @@ +meta { + name: 3 Preview Batch - With Envelope + type: http + seq: 3 +} + +post { + url: {{baseUrl}}/api/preview/batch + body: json + auth: none +} + +body:json { + { + "sessionId": "{{sessionId}}", + "batchIndex": 0, + "forceRegenerate": false, + "config": { + "font": "alva", + "letters": [ + { + "index": 0, + "format": "a6p", + "text": "Liebe [[Vorname]],\n\nvielen Dank für deine Bestellung!\n\nHerzliche Grüße", + "placeholders": { + "Vorname": "Anna", + "Nachname": "Schmidt", + "Strasse": "Bahnhofstr. 5", + "PLZ": "80331", + "Ort": "München" + } + } + ], + "envelopes": [ + { + "index": 0, + "format": "c6", + "type": "recipient", + "data": { + "Vorname": "Anna", + "Nachname": "Schmidt", + "Strasse": "Bahnhofstr. 5", + "PLZ": "80331", + "Ort": "München" + } + } + ] + } + } +} + +tests { + test("Returns letter and envelope", function() { + const files = res.body.files; + const hasLetter = files.some(f => f.type === "letter"); + const hasEnvelope = files.some(f => f.type === "envelope"); + expect(hasLetter).to.be.true; + expect(hasEnvelope).to.be.true; + }); +} diff --git a/Docker Backend/bruno-tests/4 Preview Batch - Custom Envelope Text.bru b/Docker Backend/bruno-tests/4 Preview Batch - Custom Envelope Text.bru new file mode 100644 index 0000000..22fed3d --- /dev/null +++ b/Docker Backend/bruno-tests/4 Preview Batch - Custom Envelope Text.bru @@ -0,0 +1,39 @@ +meta { + name: 4 Preview Batch - Custom Envelope Text + type: http + seq: 4 +} + +post { + url: {{baseUrl}}/api/preview/batch + body: json + auth: none +} + +body:json { + { + "sessionId": "{{sessionId}}-custom", + "batchIndex": 0, + "config": { + "font": "ellie", + "letters": [ + { + "index": 0, + "format": "a6l", + "text": "Alles Gute zum Geburtstag!", + "placeholders": {} + } + ], + "envelopes": [ + { + "index": 0, + "format": "din_lang", + "type": "custom", + "data": { + "customText": "Für meine großartige Freundin Caro" + } + } + ] + } + } +} diff --git a/Docker Backend/bruno-tests/5 Order Finalize.bru b/Docker Backend/bruno-tests/5 Order Finalize.bru new file mode 100644 index 0000000..51bf2b9 --- /dev/null +++ b/Docker Backend/bruno-tests/5 Order Finalize.bru @@ -0,0 +1,39 @@ +meta { + name: 5 Order Finalize + type: http + seq: 5 +} + +post { + url: {{baseUrl}}/api/order/finalize + body: json + auth: none +} + +body:json { + { + "sessionId": "{{sessionId}}", + "orderNumber": "{{orderNumber}}" + } +} + +docs { + Finalisiert eine Bestellung aus dem Preview-Cache. + + WICHTIG: Vorher muss ein Preview Batch generiert worden sein! + Run "2 Preview Batch - Single Letter" first. +} + +tests { + test("Status is 200", function() { + expect(res.status).to.equal(200); + }); + + test("Returns order number", function() { + expect(res.body.orderNumber).to.equal(bru.getEnvVar("orderNumber")); + }); + + test("Has output path", function() { + expect(res.body.outputPath).to.be.a("string"); + }); +} diff --git a/Docker Backend/bruno-tests/6 Order Generate.bru b/Docker Backend/bruno-tests/6 Order Generate.bru new file mode 100644 index 0000000..18742ac --- /dev/null +++ b/Docker Backend/bruno-tests/6 Order Generate.bru @@ -0,0 +1,96 @@ +meta { + name: 6 Order Generate + type: http + seq: 6 +} + +post { + url: {{baseUrl}}/api/order/generate + body: json + auth: none +} + +body:json { + { + "orderNumber": "SK-2026-01-15-002", + "config": { + "font": "tilda", + "letters": [ + { + "index": 0, + "format": "a4", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung.\n\nMit freundlichen Grüßen", + "placeholders": { + "Vorname": "Thomas", + "Nachname": "Müller", + "Strasse": "Lindenweg 12", + "PLZ": "50667", + "Ort": "Köln" + } + }, + { + "index": 1, + "format": "a4", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung.\n\nMit freundlichen Grüßen", + "placeholders": { + "Vorname": "Julia", + "Nachname": "Weber", + "Strasse": "Kastanienallee 7", + "PLZ": "60311", + "Ort": "Frankfurt" + } + } + ], + "envelopes": [ + { + "index": 0, + "format": "c6", + "type": "recipient", + "data": { + "Vorname": "Thomas", + "Nachname": "Müller", + "Strasse": "Lindenweg 12", + "PLZ": "50667", + "Ort": "Köln" + } + }, + { + "index": 1, + "format": "c6", + "type": "recipient", + "data": { + "Vorname": "Julia", + "Nachname": "Weber", + "Strasse": "Kastanienallee 7", + "PLZ": "60311", + "Ort": "Frankfurt" + } + } + ] + } + } +} + +docs { + Generiert eine Bestellung direkt (ohne Preview-Cache). + + Use case: Retry nach Fehler oder manuelles Regenerieren. +} + +tests { + test("Status is 200", function() { + expect(res.status).to.equal(200); + }); + + test("Generated 2 letters", function() { + expect(res.body.files.letters).to.equal(2); + }); + + test("Generated 2 envelopes", function() { + expect(res.body.files.envelopes).to.equal(2); + }); + + test("Has CSV", function() { + expect(res.body.files.csv).to.be.a("string"); + }); +} diff --git a/Docker Backend/bruno-tests/bruno.json b/Docker Backend/bruno-tests/bruno.json new file mode 100644 index 0000000..e7b8ead --- /dev/null +++ b/Docker Backend/bruno-tests/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "Skrift Backend API", + "type": "collection" +} diff --git a/Docker Backend/bruno-tests/environments/Local.bru b/Docker Backend/bruno-tests/environments/Local.bru new file mode 100644 index 0000000..6923054 --- /dev/null +++ b/Docker Backend/bruno-tests/environments/Local.bru @@ -0,0 +1,5 @@ +vars { + baseUrl: http://localhost:4000 + sessionId: test-session-{{$timestamp}} + orderNumber: SK-2026-01-15-001 +} diff --git a/Docker Backend/deploy.sh b/Docker Backend/deploy.sh new file mode 100644 index 0000000..0790457 --- /dev/null +++ b/Docker Backend/deploy.sh @@ -0,0 +1,252 @@ +#!/bin/bash + +# Skrift Backend - Deployment Script für Server +# Dieses Script automatisiert das Deployment auf dem Server + +set -e # Exit bei Fehler + +echo "=========================================" +echo "Skrift Backend - Deployment Script" +echo "=========================================" +echo "" + +# Konfiguration +DEPLOY_USER="root" +DEPLOY_HOST="" +DEPLOY_PATH="/opt/skrift-backend" +DOCKER_IMAGE_NAME="skrift-backend" +DOCKER_HUB_USER="" + +# Funktion: Hilfe anzeigen +show_help() { + echo "Verwendung: ./deploy.sh [OPTION]" + echo "" + echo "Optionen:" + echo " local - Docker Image lokal bauen und testen" + echo " push - Image zu Docker Hub pushen" + echo " server-scp - Backend per SCP zum Server kopieren" + echo " server-deploy - Auf Server deployen (via Docker Hub)" + echo " server-ssh - SSH-Verbindung zum Server öffnen" + echo " help - Diese Hilfe anzeigen" + echo "" + echo "Beispiele:" + echo " ./deploy.sh local # Lokal testen" + echo " ./deploy.sh push # Image hochladen" + echo " ./deploy.sh server-deploy # Auf Server deployen" +} + +# Funktion: Konfiguration prüfen +check_config() { + if [ -z "$DEPLOY_HOST" ]; then + echo "ERROR: DEPLOY_HOST ist nicht gesetzt!" + echo "Bitte in diesem Script DEPLOY_HOST='dein-server.de' setzen" + exit 1 + fi + + if [ "$1" == "push" ] && [ -z "$DOCKER_HUB_USER" ]; then + echo "ERROR: DOCKER_HUB_USER ist nicht gesetzt!" + echo "Bitte in diesem Script DOCKER_HUB_USER='deinusername' setzen" + exit 1 + fi +} + +# Funktion: Lokal bauen und testen +build_local() { + echo "Building Docker Image lokal..." + docker build -t $DOCKER_IMAGE_NAME:latest . + + echo "" + echo "Image erfolgreich gebaut!" + echo "" + echo "Zum Testen:" + echo " docker run -p 4000:4000 --env-file .env -v \$(pwd)/fonts:/app/fonts:ro $DOCKER_IMAGE_NAME:latest" + echo "" + echo "Dann im Browser: http://localhost:4000/health" +} + +# Funktion: Image zu Docker Hub pushen +push_dockerhub() { + check_config "push" + + echo "Logging in to Docker Hub..." + docker login + + echo "Tagging image..." + docker tag $DOCKER_IMAGE_NAME:latest $DOCKER_HUB_USER/$DOCKER_IMAGE_NAME:latest + + echo "Pushing to Docker Hub..." + docker push $DOCKER_HUB_USER/$DOCKER_IMAGE_NAME:latest + + echo "" + echo "Image erfolgreich gepusht: $DOCKER_HUB_USER/$DOCKER_IMAGE_NAME:latest" +} + +# Funktion: Backend per SCP zum Server kopieren +deploy_scp() { + check_config + + echo "Kopiere Backend-Dateien zum Server..." + + # Temporäres Verzeichnis für saubere Dateien erstellen + echo "Erstelle sauberes Deployment-Package..." + rm -rf ./deploy-temp + mkdir -p ./deploy-temp + + # Nur notwendige Dateien kopieren + rsync -av \ + --exclude 'node_modules' \ + --exclude 'output' \ + --exclude 'cache' \ + --exclude '.git' \ + --exclude 'deploy-temp' \ + --exclude '*.md' \ + --exclude 'bruno-tests' \ + --exclude 'test-*.js' \ + --exclude 'generate-*.js' \ + ./ ./deploy-temp/ + + # Zum Server kopieren + echo "Uploading to $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH..." + ssh $DEPLOY_USER@$DEPLOY_HOST "mkdir -p $DEPLOY_PATH" + rsync -avz ./deploy-temp/ $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/ + + # Fonts extra kopieren + echo "Uploading fonts..." + scp ./fonts/*.svg $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/fonts/ + + # Cleanup + rm -rf ./deploy-temp + + echo "" + echo "Dateien erfolgreich hochgeladen!" + echo "" + echo "Nächste Schritte auf dem Server:" + echo " ssh $DEPLOY_USER@$DEPLOY_HOST" + echo " cd $DEPLOY_PATH" + echo " nano .env # .env Datei erstellen!" + echo " mkdir -p /var/skrift-output" + echo " docker-compose up -d --build" +} + +# Funktion: Auf Server deployen (via Docker Hub) +deploy_server() { + check_config "push" + + echo "Deploying to server via Docker Hub..." + + # SSH Befehle auf Server ausführen + ssh $DEPLOY_USER@$DEPLOY_HOST << ENDSSH +set -e + +echo "Creating directories..." +mkdir -p $DEPLOY_PATH +mkdir -p /var/skrift-output +chmod 755 /var/skrift-output + +cd $DEPLOY_PATH + +echo "Checking if .env exists..." +if [ ! -f .env ]; then + echo "ERROR: .env Datei fehlt!" + echo "Bitte zuerst .env erstellen mit:" + echo " SCRIPTALIZER_LICENSE_KEY=..." + echo " SCRIPTALIZER_ERR_FREQUENCY=0" + exit 1 +fi + +echo "Checking if docker-compose.yml exists..." +if [ ! -f docker-compose.yml ]; then + echo "Creating docker-compose.yml..." + cat > docker-compose.yml << 'EOF' +version: '3.8' + +services: + skrift-backend: + image: $DOCKER_HUB_USER/$DOCKER_IMAGE_NAME:latest + container_name: skrift-backend + restart: unless-stopped + ports: + - "4000:4000" + environment: + - NODE_ENV=production + - PORT=4000 + - SCRIPTALIZER_LICENSE_KEY=\${SCRIPTALIZER_LICENSE_KEY} + - SCRIPTALIZER_ERR_FREQUENCY=0 + - BATCH_SIZE=30 + - CACHE_LIFETIME_HOURS=2 + - RATE_LIMIT_PER_MINUTE=2 + volumes: + - ./fonts:/app/fonts:ro + - skrift-cache:/app/cache + - /var/skrift-output:/app/output + networks: + - skrift-network + +volumes: + skrift-cache: + driver: local + +networks: + skrift-network: + driver: bridge +EOF +fi + +echo "Pulling latest image..." +docker-compose pull + +echo "Starting containers..." +docker-compose up -d + +echo "Waiting for container to be healthy..." +sleep 5 + +echo "Checking health..." +docker-compose ps + +echo "" +echo "Deployment complete!" +echo "Check logs with: docker-compose logs -f" +ENDSSH + + echo "" + echo "Deployment auf Server abgeschlossen!" + echo "" + echo "Health-Check:" + echo " curl http://$DEPLOY_HOST:4000/health" +} + +# Funktion: SSH zum Server +ssh_server() { + check_config + echo "Connecting to $DEPLOY_USER@$DEPLOY_HOST..." + ssh $DEPLOY_USER@$DEPLOY_HOST +} + +# Main Script +case "$1" in + local) + build_local + ;; + push) + push_dockerhub + ;; + server-scp) + deploy_scp + ;; + server-deploy) + deploy_server + ;; + server-ssh) + ssh_server + ;; + help|--help|-h|"") + show_help + ;; + *) + echo "Unbekannte Option: $1" + echo "" + show_help + exit 1 + ;; +esac diff --git a/Docker Backend/docker-compose.yml b/Docker Backend/docker-compose.yml new file mode 100644 index 0000000..6dec2fd --- /dev/null +++ b/Docker Backend/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + skrift-backend: + build: . + container_name: skrift-backend + restart: unless-stopped + ports: + - "4000:4000" + environment: + - NODE_ENV=${NODE_ENV:-production} + - PORT=4000 + - SCRIPTALIZER_LICENSE_KEY=${SCRIPTALIZER_LICENSE_KEY} + - SCRIPTALIZER_ERR_FREQUENCY=${SCRIPTALIZER_ERR_FREQUENCY:-10} + - BATCH_SIZE=${BATCH_SIZE:-30} + - CACHE_LIFETIME_HOURS=${CACHE_LIFETIME_HOURS:-2} + - RATE_LIMIT_PER_MINUTE=${RATE_LIMIT_PER_MINUTE:-2} + volumes: + - ./fonts:/app/fonts:ro # SVG Fonts (read-only) + - skrift-cache:/app/cache # Preview cache (temporary) + - /var/skrift-output:/app/output # Output files for N8N + networks: + - skrift-network + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4000/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + +volumes: + skrift-cache: + driver: local + +networks: + skrift-network: + driver: bridge diff --git a/Docker Backend/fonts/alva.svg b/Docker Backend/fonts/alva.svg new file mode 100644 index 0000000..1f2d1c7 --- /dev/null +++ b/Docker Backend/fonts/alva.svg @@ -0,0 +1,2224 @@ + + + + Created by FontForge 20200511 at Fri Mar 22 09:00:32 2024 By convertio Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Docker Backend/fonts/ellie.svg b/Docker Backend/fonts/ellie.svg new file mode 100644 index 0000000..aa1584e --- /dev/null +++ b/Docker Backend/fonts/ellie.svg @@ -0,0 +1,2033 @@ + + + + +Created by FontForge 20200306 at Sun Aug 14 09:09:30 2022 + By convertio +Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Docker Backend/fonts/tilda.svg b/Docker Backend/fonts/tilda.svg new file mode 100644 index 0000000..fc517bd --- /dev/null +++ b/Docker Backend/fonts/tilda.svg @@ -0,0 +1,2319 @@ + + + + +Created by FontForge 20200511 at Wed Nov 19 10:12:13 2025 + By convertio +Copyright: Quantum Enterprises + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Docker Backend/generate-9-orders.js b/Docker Backend/generate-9-orders.js new file mode 100644 index 0000000..c43d44b --- /dev/null +++ b/Docker Backend/generate-9-orders.js @@ -0,0 +1,258 @@ +/** + * Generiert 9 Bestellungen mit allen Font/Format/Umschlag Kombinationen + */ + +const http = require('http'); + +const API_URL = 'localhost'; +const API_PORT = 4000; + +// Texte +const TEXT_A4 = `Sehr geehrte Damen und Herren, + +hiermit bestätigen wir den Eingang Ihrer Bestellung und bedanken uns herzlich für Ihr Vertrauen. + +Ihre Bestellung wird schnellstmöglich bearbeitet und versendet. Sie erhalten in Kürze eine separate Versandbestätigung mit der Sendungsverfolgungsnummer. + +Sollten Sie Fragen zu Ihrer Bestellung haben, stehen wir Ihnen jederzeit gerne zur Verfügung. + +Wir wünschen Ihnen viel Freude mit Ihren bestellten Produkten und freuen uns auf eine weitere Zusammenarbeit. + +Mit freundlichen Grüßen +Ihr Skrift-Team`; + +const TEXT_A6 = `Liebe Grüße! + +Vielen Dank für Ihre Bestellung. + +Wir wünschen Ihnen einen wundervollen Tag! + +Herzlichst, +Ihr Skrift-Team`; + +const orders = [ + // TILDA + { + orderNumber: 'SK-2026-01-02-TILDA-A4', + font: 'tilda', + format: 'a4', + text: TEXT_A4, + envelopeFormat: 'c6', + envelopeType: 'recipient' + }, + { + orderNumber: 'SK-2026-01-02-TILDA-A6Q', + font: 'tilda', + format: 'a6l', + text: TEXT_A6, + envelopeFormat: 'din_lang', + envelopeType: 'recipient' + }, + { + orderNumber: 'SK-2026-01-02-TILDA-A6H', + font: 'tilda', + format: 'a6p', + text: TEXT_A6, + envelopeFormat: 'c6', + envelopeType: 'custom' + }, + // ALVA + { + orderNumber: 'SK-2026-01-02-ALVA-A4', + font: 'alva', + format: 'a4', + text: TEXT_A4, + envelopeFormat: 'c6', + envelopeType: 'recipient' + }, + { + orderNumber: 'SK-2026-01-02-ALVA-A6Q', + font: 'alva', + format: 'a6l', + text: TEXT_A6, + envelopeFormat: 'din_lang', + envelopeType: 'recipient' + }, + { + orderNumber: 'SK-2026-01-02-ALVA-A6H', + font: 'alva', + format: 'a6p', + text: TEXT_A6, + envelopeFormat: 'c6', + envelopeType: 'custom' + }, + // ELLIE + { + orderNumber: 'SK-2026-01-02-ELLIE-A4', + font: 'ellie', + format: 'a4', + text: TEXT_A4, + envelopeFormat: 'c6', + envelopeType: 'recipient' + }, + { + orderNumber: 'SK-2026-01-02-ELLIE-A6Q', + font: 'ellie', + format: 'a6l', + text: TEXT_A6, + envelopeFormat: 'din_lang', + envelopeType: 'recipient' + }, + { + orderNumber: 'SK-2026-01-02-ELLIE-A6H', + font: 'ellie', + format: 'a6p', + text: TEXT_A6, + envelopeFormat: 'c6', + envelopeType: 'custom' + } +]; + +async function generateOrder(order) { + return new Promise((resolve, reject) => { + const envelope = order.envelopeType === 'recipient' + ? { + index: 0, + format: order.envelopeFormat, + font: order.font, + type: 'recipient', + data: { + Vorname: 'Max', + Nachname: 'Mustermann', + Strasse: 'Hauptstraße 1', + PLZ: '10115', + Ort: 'Berlin' + } + } + : { + index: 0, + format: order.envelopeFormat, + font: order.font, + type: 'custom', + data: { + customText: 'Für unsere geschätzten Kunden' + } + }; + + const requestBody = { + orderNumber: order.orderNumber, + letters: [ + { + index: 0, + format: order.format, + font: order.font, + text: order.text, + placeholders: {} + } + ], + envelopes: [envelope] + }; + + const body = JSON.stringify(requestBody); + + const options = { + hostname: API_URL, + port: API_PORT, + path: '/api/order/generate', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) + }, + timeout: 60000 + }; + + const req = http.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const result = JSON.parse(data); + resolve({ order: order.orderNumber, result }); + } catch (err) { + reject({ order: order.orderNumber, error: err.message, data }); + } + }); + }); + + req.on('error', (err) => { + reject({ order: order.orderNumber, error: err.message }); + }); + + req.on('timeout', () => { + req.destroy(); + reject({ order: order.orderNumber, error: 'timeout' }); + }); + + req.write(body); + req.end(); + }); +} + +async function generateAllOrders() { + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ GENERIERE 9 BESTELLUNGEN ║'); + console.log('║ 3 Fonts × 3 Formate = 9 Kombinationen ║'); + console.log('╚════════════════════════════════════════════════════════════╝\n'); + + const results = []; + + for (let i = 0; i < orders.length; i++) { + const order = orders[i]; + const num = i + 1; + + console.log(`\n[${num}/9] Generiere: ${order.orderNumber}`); + console.log(` Font: ${order.font.toUpperCase()}`); + console.log(` Format: ${order.format.toUpperCase()}`); + console.log(` Umschlag: ${order.envelopeFormat} (${order.envelopeType})`); + + try { + const result = await generateOrder(order); + console.log(` ✅ Erfolgreich generiert`); + results.push(result); + } catch (err) { + console.log(` ❌ Fehler: ${err.error || err.message}`); + results.push({ order: order.orderNumber, error: err }); + } + + // Pause zwischen Requests (Rate Limiting) + if (i < orders.length - 1) { + console.log(` ⏳ Warte 3 Sekunden...`); + await new Promise(resolve => setTimeout(resolve, 3000)); + } + } + + console.log('\n\n' + '═'.repeat(60)); + console.log('ZUSAMMENFASSUNG'); + console.log('═'.repeat(60)); + + const successful = results.filter(r => !r.error); + const failed = results.filter(r => r.error); + + console.log(`\n✅ Erfolgreich: ${successful.length}/9`); + console.log(`❌ Fehlgeschlagen: ${failed.length}/9`); + + if (successful.length > 0) { + console.log('\n📦 Generierte Bestellungen:'); + successful.forEach(r => { + console.log(` - ${r.order}`); + }); + } + + if (failed.length > 0) { + console.log('\n❌ Fehlgeschlagene Bestellungen:'); + failed.forEach(r => { + console.log(` - ${r.order}: ${r.error.error || r.error.message}`); + }); + } + + console.log('\n' + '═'.repeat(60)); + console.log('\n💾 Alle Dateien in: E:\\Dokumente\\05_Skrift\\Frontend_Backend_Konfigurator\\Docker Backend\\output\\'); +} + +// Start +generateAllOrders().catch(console.error); diff --git a/Docker Backend/package-lock.json b/Docker Backend/package-lock.json new file mode 100644 index 0000000..5d64b96 --- /dev/null +++ b/Docker Backend/package-lock.json @@ -0,0 +1,1624 @@ +{ + "name": "skrift-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skrift-backend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@paypal/paypal-server-sdk": "^1.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.0", + "multer": "^2.0.2", + "uuid": "^9.0.1", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } + }, + "node_modules/@apimatic/authentication-adapters": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@apimatic/authentication-adapters/-/authentication-adapters-0.5.14.tgz", + "integrity": "sha512-V7nhHShPrU8LfjKKHoVJNS50SveSL77CexVuS4aeQyXx99HwdQVJwl2MK0KAYM6/b2ufQbJ7Eee2fzQT0TVXSQ==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/axios-client-adapter": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/@apimatic/axios-client-adapter/-/axios-client-adapter-0.3.21.tgz", + "integrity": "sha512-pr/XvAvH9FjbpwM+B7vHQxM7alocOX1kLNtSpXKW3yxTYxksF3ydnUuQ85rRbCoNpyfMOIjnRBCNUBzX5p2Hnw==", + "license": "MIT", + "dependencies": { + "@apimatic/convert-to-stream": "^0.1.9", + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "@apimatic/proxy": "^0.1.4", + "axios": "^1.8.4", + "detect-browser": "^5.3.0", + "detect-node": "^2.1.0", + "form-data": "^4.0.1", + "lodash.flatmap": "^4.5.0", + "tiny-warning": "^1.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/convert-to-stream": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/@apimatic/convert-to-stream/-/convert-to-stream-0.1.9.tgz", + "integrity": "sha512-C9NEKnDZoTRBRVeUGXVyAEmy6P5o+8oLwEckTKj0iBlExJLEXNt14nf4wxfzRO1KR8j5Bw8S6yStKCrQzcVERA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/core": { + "version": "0.10.29", + "resolved": "https://registry.npmjs.org/@apimatic/core/-/core-0.10.29.tgz", + "integrity": "sha512-QhORiq0QbjlDMrw8ZZsAeG2DzE6QgGz5ukD5w2MOWE/3iIWnUDEROjmc2SfhyiGsE3GoEJ8cyhMMdjlOioP6ww==", + "license": "MIT", + "dependencies": { + "@apimatic/convert-to-stream": "^0.1.9", + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "@apimatic/http-query": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "@apimatic/schema": "^0.7.21", + "detect-browser": "^5.3.0", + "detect-node": "^2.1.0", + "form-data": "^4.0.1", + "lodash.defaultsdeep": "^4.6.1", + "lodash.flatmap": "^4.5.0", + "tiny-warning": "^1.0.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/core-interfaces": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@apimatic/core-interfaces/-/core-interfaces-0.2.14.tgz", + "integrity": "sha512-PQmSU32ndxtDddMCjbkNY/sVvDwQAsHUGKrdG5aGVE7iw/qvB2Tm2zyCarOB5TlDr4OB+/tuLCVhji0icx6MHg==", + "license": "MIT", + "dependencies": { + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/json-bigint": "^1.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/file-wrapper": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@apimatic/file-wrapper/-/file-wrapper-0.3.9.tgz", + "integrity": "sha512-Fh3UE7UPs2v4wkJdsD+uJFF147+7X0qkQfKBdeLZx6mZ5RmBJOBbS6ApvstQTV279YsHiiedKUZGJ6XLoVU+pQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/http-headers": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@apimatic/http-headers/-/http-headers-0.3.8.tgz", + "integrity": "sha512-ShvCuT39hYfBTI+H1I16m5i6XZCyUy2kQJ6Jhfj78TwsW5r6AyCbzW7DEro8GN2nNYRU1+E/hrgH6J85YmriOA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/http-query": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@apimatic/http-query/-/http-query-0.3.9.tgz", + "integrity": "sha512-D6nqXcCR3P6iWbJ9uFXyyF2z1PEhTbGFbHNNuwF1NQ4tnThQk67DW9ou7/XcWi21zLh9MUchDWw9I0iE+5F2xA==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/json-bigint": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@apimatic/json-bigint/-/json-bigint-1.2.0.tgz", + "integrity": "sha512-+bmVzYMdZu0Ya5L+my4FXFUih54OvQA/qlZsFOYdOoostyUuB27UDrVWQs/WVCmS0ADdo5vTU0eeTrrBkHoySw==", + "license": "MIT" + }, + "node_modules/@apimatic/oauth-adapters": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/@apimatic/oauth-adapters/-/oauth-adapters-0.4.18.tgz", + "integrity": "sha512-MpRyBgKWg3yINQR85tBPmtU/596P0gdj4RmQ3s4D1LRuwDzqRO8GpRq74J8hxQ7vLQvH0OfGh6zzhr1KI4dQRQ==", + "license": "MIT", + "dependencies": { + "@apimatic/core-interfaces": "^0.2.14", + "@apimatic/file-wrapper": "^0.3.9", + "@apimatic/http-headers": "^0.3.8", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/proxy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@apimatic/proxy/-/proxy-0.1.4.tgz", + "integrity": "sha512-Vzgfu7wcA5aEJyj2SjQ00Tb06fhBof8gDo1kSsF6sZBm4QjdFywN5AMbQwhfFOKjHqcsNmJspdeqcdymUQ77jA==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@apimatic/schema": { + "version": "0.7.21", + "resolved": "https://registry.npmjs.org/@apimatic/schema/-/schema-0.7.21.tgz", + "integrity": "sha512-RCke4toXjA7fBRxQVa1GR+Lj9utVOEJ3voDI26dhk+bZuAac4UXPzkTEaIO3AIe/o8pcKCOkpNIzhzm57Cv2Qg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.15.0 || >=16.0.0" + } + }, + "node_modules/@paypal/paypal-server-sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@paypal/paypal-server-sdk/-/paypal-server-sdk-1.1.0.tgz", + "integrity": "sha512-MaJQmSVpQb5nEeZWZDTtGRxeaHj6O51G9Olmm3zQ5E4XNgR4emAI08CKC2ZPhBldVoxAoBVjLyvKjGV5lC1n2Q==", + "license": "MIT", + "dependencies": { + "@apimatic/authentication-adapters": "^0.5.4", + "@apimatic/axios-client-adapter": "^0.3.7", + "@apimatic/core": "^0.10.16", + "@apimatic/oauth-adapters": "^0.4.8", + "@apimatic/schema": "^0.7.14" + }, + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-browser": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.3.0.tgz", + "integrity": "sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==", + "license": "MIT" + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lodash.defaultsdeep": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.1.tgz", + "integrity": "sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==", + "license": "MIT" + }, + "node_modules/lodash.flatmap": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.flatmap/-/lodash.flatmap-4.5.0.tgz", + "integrity": "sha512-/OcpcAGWlrZyoHGeHh3cAoa6nGdX6QYtmzNP84Jqol6UEQQ2gIaU3H+0eICcjcKGl0/XF8LWOujNn9lffsnaOg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.1", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.3", + "license": "BlueOak-1.0.0" + }, + "node_modules/semver": { + "version": "7.7.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/Docker Backend/package.json b/Docker Backend/package.json new file mode 100644 index 0000000..936d28e --- /dev/null +++ b/Docker Backend/package.json @@ -0,0 +1,31 @@ +{ + "name": "skrift-backend", + "version": "1.0.0", + "description": "Backend for Skrift handwritten document configurator", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js", + "test:separator": "node test-scriptalizer.js" + }, + "keywords": [ + "skrift", + "handwriting", + "svg", + "scriptalizer" + ], + "author": "", + "license": "ISC", + "dependencies": { + "@paypal/paypal-server-sdk": "^1.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.0", + "multer": "^2.0.2", + "uuid": "^9.0.1", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "nodemon": "^3.0.2" + } +} diff --git a/Docker Backend/prepare-deployment.js b/Docker Backend/prepare-deployment.js new file mode 100644 index 0000000..45e1b59 --- /dev/null +++ b/Docker Backend/prepare-deployment.js @@ -0,0 +1,406 @@ +const fs = require('fs').promises; +const fsSync = require('fs'); +const path = require('path'); + +const SOURCE_DIR = __dirname; +const DEPLOY_DIR = path.join(__dirname, '..', 'DEPLOYMENT_READY'); + +// Dateien und Ordner die KOPIERT werden sollen +const INCLUDE_FILES = [ + // Docker + 'Dockerfile', + 'docker-compose.yml', + '.dockerignore', + + // Package + 'package.json', + 'package-lock.json', + + // Source Code + 'src/**/*', + + // Fonts + 'fonts/**/*.svg', + + // Config Example + '.env.example' +]; + +// Dateien die NICHT kopiert werden sollen +const EXCLUDE_PATTERNS = [ + 'node_modules', + 'output', + 'cache', + '.git', + 'bruno-tests', + '*.md', + 'test-*.js', + 'test-*.json', + 'test-*.sh', + 'generate-*.js', + 'server.log', + '.env', + 'deploy.sh', + 'prepare-deployment.js', + 'DEPLOYMENT_READY' +]; + +function shouldExclude(filePath) { + const relativePath = path.relative(SOURCE_DIR, filePath); + + return EXCLUDE_PATTERNS.some(pattern => { + if (pattern.includes('*')) { + const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); + return regex.test(path.basename(filePath)) || regex.test(relativePath); + } + return relativePath.startsWith(pattern) || path.basename(filePath) === pattern; + }); +} + +async function ensureDir(dir) { + try { + await fs.mkdir(dir, { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } +} + +async function copyFile(src, dest) { + await ensureDir(path.dirname(dest)); + await fs.copyFile(src, dest); +} + +async function removeDir(dir) { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await removeDir(fullPath); + } else { + await fs.unlink(fullPath); + } + } + await fs.rmdir(dir); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } +} + +async function pathExists(p) { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function copyDirectory(src, dest) { + const entries = await fs.readdir(src, { withFileTypes: true }); + + await ensureDir(dest); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (shouldExclude(srcPath)) { + console.log(`⏭️ Skipping: ${path.relative(SOURCE_DIR, srcPath)}`); + continue; + } + + if (entry.isDirectory()) { + await copyDirectory(srcPath, destPath); + } else { + await copyFile(srcPath, destPath); + console.log(`✅ Copied: ${path.relative(SOURCE_DIR, srcPath)}`); + } + } +} + +async function main() { + console.log('🚀 Preparing Deployment Package...\n'); + + // Deployment-Verzeichnis erstellen/leeren + if (await pathExists(DEPLOY_DIR)) { + console.log('🗑️ Cleaning existing deployment directory...'); + await removeDir(DEPLOY_DIR); + } + + await ensureDir(DEPLOY_DIR); + console.log(`📁 Created: ${DEPLOY_DIR}\n`); + + // Dateien kopieren + console.log('📋 Copying production files...\n'); + await copyDirectory(SOURCE_DIR, DEPLOY_DIR); + + // Produktions-.env.example erstellen + const envExample = `# Skrift Backend - Production Environment Variables + +# Scriptalizer API Configuration +SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66 +SCRIPTALIZER_ERR_FREQUENCY=0 + +# Preview Settings +BATCH_SIZE=30 +CACHE_LIFETIME_HOURS=2 +RATE_LIMIT_PER_MINUTE=2 + +# Environment +NODE_ENV=production +PORT=4000 +`; + + await fs.writeFile(path.join(DEPLOY_DIR, '.env.example'), envExample); + console.log('✅ Created: .env.example\n'); + + // README für Deployment erstellen + const deployReadme = `# Skrift Backend - Deployment Package + +Dieses Verzeichnis enthält alle notwendigen Dateien für das Deployment auf den Server. + +## Schnellstart + +### 1. Zum Server kopieren + +\`\`\`bash +# Mit SCP +scp -r * root@DEIN-SERVER:/opt/skrift-backend/ + +# Oder mit rsync (falls verfügbar) +rsync -avz ./ root@DEIN-SERVER:/opt/skrift-backend/ +\`\`\` + +### 2. Auf dem Server einrichten + +\`\`\`bash +ssh root@DEIN-SERVER + +cd /opt/skrift-backend + +# .env erstellen (aus .env.example) +cp .env.example .env +nano .env # Prüfen und anpassen falls nötig + +# Output-Verzeichnis erstellen +mkdir -p /var/skrift-output +chmod 755 /var/skrift-output + +# Container starten +docker-compose up -d --build + +# Logs prüfen +docker-compose logs -f +\`\`\` + +### 3. Testen + +\`\`\`bash +curl http://localhost:4000/health +\`\`\` + +## Enthaltene Dateien + +- \`src/\` - Backend Source Code +- \`fonts/\` - SVG Fonts (Tilda, Alva, Ellie) +- \`Dockerfile\` - Docker Image Konfiguration +- \`docker-compose.yml\` - Docker Compose Konfiguration +- \`package.json\` - Node.js Dependencies +- \`.env.example\` - Environment Variables Template + +## Wichtig + +⚠️ **SCRIPTALIZER_ERR_FREQUENCY=0** ist bereits gesetzt - keine durchgestrichenen Wörter! + +## Nginx Proxy Manager + +Nach dem Start in Nginx Proxy Manager konfigurieren: +- Domain: backend.deine-domain.de +- Forward to: skrift-backend:4000 +- SSL: Let's Encrypt aktivieren + +## Support + +Bei Problemen: \`docker-compose logs -f\` +`; + + await fs.writeFile(path.join(DEPLOY_DIR, 'README.txt'), deployReadme); + console.log('✅ Created: README.txt\n'); + + // Upload-Script erstellen + const uploadScript = `#!/bin/bash + +# Skrift Backend - Upload Script +# Dieses Script lädt das Backend auf den Server hoch + +# KONFIGURATION - BITTE ANPASSEN! +SERVER_USER="root" +SERVER_HOST="" # z.B. "123.456.789.0" oder "dein-server.de" +SERVER_PATH="/opt/skrift-backend" + +# Farben für Output +RED='\\033[0;31m' +GREEN='\\033[0;32m' +YELLOW='\\033[1;33m' +NC='\\033[0m' # No Color + +# Funktion: Fehler anzeigen und beenden +error_exit() { + echo -e "\${RED}❌ ERROR: \$1\${NC}" >&2 + exit 1 +} + +# Prüfen ob Server konfiguriert ist +if [ -z "$SERVER_HOST" ]; then + error_exit "SERVER_HOST ist nicht gesetzt! Bitte in upload.sh die Variable SERVER_HOST setzen." +fi + +echo -e "\${GREEN}🚀 Skrift Backend Upload\${NC}" +echo "======================================" +echo "Server: \$SERVER_USER@\$SERVER_HOST" +echo "Path: \$SERVER_PATH" +echo "" + +# Prüfen ob SSH-Verbindung funktioniert +echo -e "\${YELLOW}🔍 Testing SSH connection...\${NC}" +ssh -o ConnectTimeout=5 -o BatchMode=yes \$SERVER_USER@\$SERVER_HOST "echo '✅ SSH connection successful'" || error_exit "SSH connection failed" +echo "" + +# Verzeichnis auf Server erstellen +echo -e "\${YELLOW}📁 Creating directory on server...\${NC}" +ssh \$SERVER_USER@\$SERVER_HOST "mkdir -p \$SERVER_PATH" || error_exit "Failed to create directory" +echo "" + +# Dateien hochladen +echo -e "\${YELLOW}📤 Uploading files...\${NC}" +scp -r * \$SERVER_USER@\$SERVER_HOST:\$SERVER_PATH/ || error_exit "Upload failed" +echo "" + +echo -e "\${GREEN}✅ Upload successful!\${NC}" +echo "" +echo "Next steps:" +echo "1. SSH to server: ssh \$SERVER_USER@\$SERVER_HOST" +echo "2. Go to directory: cd \$SERVER_PATH" +echo "3. Create .env: cp .env.example .env" +echo "4. Create output dir: mkdir -p /var/skrift-output" +echo "5. Start container: docker-compose up -d --build" +echo "6. Check logs: docker-compose logs -f" +`; + + await fs.writeFile(path.join(DEPLOY_DIR, 'upload.sh'), uploadScript); + await fs.chmod(path.join(DEPLOY_DIR, 'upload.sh'), 0o755); + console.log('✅ Created: upload.sh\n'); + + // Windows Batch Upload-Script + const uploadBat = `@echo off +REM Skrift Backend - Windows Upload Script + +REM KONFIGURATION - BITTE ANPASSEN! +set SERVER_USER=root +set SERVER_HOST= +set SERVER_PATH=/opt/skrift-backend + +if "%SERVER_HOST%"=="" ( + echo ERROR: SERVER_HOST ist nicht gesetzt! + echo Bitte in upload.bat die Variable SERVER_HOST setzen. + pause + exit /b 1 +) + +echo ======================================== +echo Skrift Backend Upload +echo ======================================== +echo Server: %SERVER_USER%@%SERVER_HOST% +echo Path: %SERVER_PATH% +echo. + +echo Uploading files... +echo. + +scp -r * %SERVER_USER%@%SERVER_HOST%:%SERVER_PATH%/ + +if %ERRORLEVEL% NEQ 0 ( + echo. + echo ERROR: Upload fehlgeschlagen! + pause + exit /b 1 +) + +echo. +echo ======================================== +echo Upload erfolgreich! +echo ======================================== +echo. +echo Naechste Schritte: +echo 1. SSH to server: ssh %SERVER_USER%@%SERVER_HOST% +echo 2. Go to directory: cd %SERVER_PATH% +echo 3. Create .env: cp .env.example .env +echo 4. Create output dir: mkdir -p /var/skrift-output +echo 5. Start container: docker-compose up -d --build +echo 6. Check logs: docker-compose logs -f +echo. +pause +`; + + await fs.writeFile(path.join(DEPLOY_DIR, 'upload.bat'), uploadBat); + console.log('✅ Created: upload.bat\n'); + + // Statistik + const stats = await getDirectoryStats(DEPLOY_DIR); + + console.log(''); + console.log('📊 Deployment Package Stats:'); + console.log('====================================='); + console.log(`📁 Total Files: ${stats.files}`); + console.log(`📂 Total Directories: ${stats.dirs}`); + console.log(`💾 Total Size: ${formatBytes(stats.size)}`); + console.log(''); + console.log('✅ Deployment Package Ready!'); + console.log(''); + console.log(`📦 Location: ${DEPLOY_DIR}`); + console.log(''); + console.log('Next steps:'); + console.log('1. Gehe ins Verzeichnis: cd DEPLOYMENT_READY'); + console.log('2. Passe upload.sh oder upload.bat an (SERVER_HOST setzen)'); + console.log('3. Führe aus: ./upload.sh (Linux/Mac) oder upload.bat (Windows)'); +} + +async function getDirectoryStats(dir) { + let files = 0; + let dirs = 0; + let size = 0; + + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + dirs++; + const subStats = await getDirectoryStats(fullPath); + files += subStats.files; + dirs += subStats.dirs; + size += subStats.size; + } else { + files++; + const stat = await fs.stat(fullPath); + size += stat.size; + } + } + + return { files, dirs, size }; +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +} + +main().catch(err => { + console.error('❌ Error:', err.message); + process.exit(1); +}); diff --git a/Docker Backend/src/api/controllers/order-controller.js b/Docker Backend/src/api/controllers/order-controller.js new file mode 100644 index 0000000..5501eaf --- /dev/null +++ b/Docker Backend/src/api/controllers/order-controller.js @@ -0,0 +1,414 @@ +const fs = require('fs').promises; +const fsSync = require('fs'); +const path = require('path'); +const config = require('../../config'); +const { scriptalizeBatch } = require('../../services/scriptalizer-service'); +const { generateLetterSVG, generateEnvelopeSVG } = require('../../lib/svg-generator'); +const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service'); + +/** + * POST /api/order/finalize + * Finalize order by copying cached previews to output directory + */ +async function finalizeOrder(req, res, next) { + try { + const { sessionId, orderNumber } = req.body; + + // Validation + if (!sessionId || !orderNumber) { + return res.status(400).json({ + error: 'Invalid request', + message: 'sessionId and orderNumber are required' + }); + } + + console.log(`[Order] Finalizing order: ${orderNumber} from session: ${sessionId}`); + if (envelopes && envelopes.length > 0) { + console.log(`[Order] Envelope data provided: ${envelopes.length} envelopes`); + } + + // Check if session cache exists + const sessionDir = path.join(config.paths.previews, sessionId); + + try { + await fs.access(sessionDir); + } catch { + return res.status(404).json({ + error: 'Session not found', + message: `Preview cache not found for session: ${sessionId}. Please generate previews first.` + }); + } + + // Check cache expiration + const metadataPath = path.join(sessionDir, '.metadata.json'); + try { + const metadataContent = await fs.readFile(metadataPath, 'utf8'); + const metadata = JSON.parse(metadataContent); + + const expiresAt = new Date(metadata.expiresAt); + if (new Date() > expiresAt) { + return res.status(410).json({ + error: 'Cache expired', + message: 'Preview cache has expired. Please regenerate previews.', + expiredAt: metadata.expiresAt + }); + } + } catch (err) { + console.warn(`[Order] Metadata not found for session: ${sessionId}`); + } + + // Create output directory and subdirectories + const outputDir = path.join(config.paths.output, orderNumber); + const envelopesDir = path.join(outputDir, 'umschlaege'); + await fs.mkdir(outputDir, { recursive: true }); + await fs.mkdir(envelopesDir, { recursive: true }); + + // Copy files from cache to output, separating letters and envelopes + const files = await fs.readdir(sessionDir); + const copiedLetters = []; + const copiedEnvelopes = []; + const copiedOther = []; + + for (const file of files) { + // Skip metadata file + if (file === '.metadata.json') { + continue; + } + + const sourcePath = path.join(sessionDir, file); + + // Determine destination based on file type + if (file.startsWith('envelope_')) { + // Umschläge in Unterordner 'umschlaege' + const destPath = path.join(envelopesDir, file); + await fs.copyFile(sourcePath, destPath); + copiedEnvelopes.push(file); + } else if (file.startsWith('letter_')) { + // Briefe im Hauptordner + const destPath = path.join(outputDir, file); + await fs.copyFile(sourcePath, destPath); + copiedLetters.push(file); + } else { + // Andere Dateien (CSV, etc.) im Hauptordner + const destPath = path.join(outputDir, file); + await fs.copyFile(sourcePath, destPath); + copiedOther.push(file); + } + } + + // Create order metadata + const orderMetadata = { + orderNumber, + sessionId, + finalizedAt: new Date().toISOString(), + letterCount: copiedLetters.length, + envelopeCount: copiedEnvelopes.length, + letters: copiedLetters, + envelopes: copiedEnvelopes, + other: copiedOther + }; + + const orderMetadataPath = path.join(outputDir, 'order-metadata.json'); + await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8'); + + const totalFiles = copiedLetters.length + copiedEnvelopes.length + copiedOther.length; + console.log(`[Order] Finalized order ${orderNumber}: ${copiedLetters.length} letters, ${copiedEnvelopes.length} envelopes, ${copiedOther.length} other files`); + + // Response + res.status(200).json({ + orderNumber, + outputPath: outputDir, + letters: copiedLetters, + envelopes: copiedEnvelopes, + other: copiedOther, + totalFiles, + envelopesGenerated: 0, + timestamp: orderMetadata.finalizedAt + }); + + } catch (err) { + console.error('[Order] Error finalizing order:', err); + next(err); + } +} + +/** + * POST /api/order/generate + * Generate order from scratch without using cache + */ +async function generateOrder(req, res, next) { + try { + const { orderNumber, letters } = req.body; + + // Validation + if (!orderNumber) { + return res.status(400).json({ + error: 'Invalid request', + message: 'orderNumber is required' + }); + } + + if (!letters || !Array.isArray(letters) || letters.length === 0) { + return res.status(400).json({ + error: 'Invalid request', + message: 'Letters array is required and must not be empty' + }); + } + + console.log(`[Order] Generating order: ${orderNumber}`); + console.log(`[Order] Total documents: ${letters.length}`); + + // Debug: Zeige welche Dokument-Typen ankommen + const letterCount = letters.filter(l => l.type !== 'envelope').length; + const envelopeCount = letters.filter(l => l.type === 'envelope').length; + console.log(`[Order] Letters: ${letterCount}, Envelopes: ${envelopeCount}`); + console.log(`[Order] Document types:`, letters.map(l => ({ index: l.index, type: l.type, envelopeType: l.envelopeType }))); + + // Create output directory and subdirectories + const outputDir = path.join(config.paths.output, orderNumber); + const envelopesDir = path.join(outputDir, 'umschlaege'); + await fs.mkdir(outputDir, { recursive: true }); + await fs.mkdir(envelopesDir, { recursive: true }); + + // Step 1: Prepare texts and replace placeholders + const processedLetters = letters.map((letter, loopIndex) => { + let text = letter.text || ''; + + // Replace placeholders if present + if (letter.placeholders && typeof letter.placeholders === 'object') { + text = replacePlaceholders(text, letter.placeholders); + } + + return { + ...letter, + // Behalte den ursprünglichen Index vom Frontend, oder verwende loopIndex als Fallback + index: letter.index !== undefined ? letter.index : loopIndex, + loopIndex, // Für scriptalizedMap Zuordnung + processedText: text, + format: letter.format || 'a4', + font: letter.font || 'tilda', + type: letter.type || 'letter', + envelopeType: letter.envelopeType || 'recipient' + }; + }); + + // Step 2: Group letters by font for batch scriptalization + const lettersByFont = {}; + + processedLetters.forEach(letter => { + const font = letter.font; + if (!lettersByFont[font]) { + lettersByFont[font] = []; + } + lettersByFont[font].push(letter); + }); + + // Step 3: Scriptalize texts by font + const scriptalizedMap = new Map(); + + for (const [font, fontLetters] of Object.entries(lettersByFont)) { + console.log(`[Order] Scriptalizing ${fontLetters.length} texts with font: ${font}`); + + const textsToScriptalize = fontLetters.map(l => l.processedText); + + try { + const scriptalizedTexts = await scriptalizeBatch(textsToScriptalize, font, config.scriptalizer.errFrequency); + + fontLetters.forEach((letter, i) => { + // Verwende loopIndex für die Map-Zuordnung, da das der Index im processedLetters Array ist + scriptalizedMap.set(letter.loopIndex, scriptalizedTexts[i] || letter.processedText); + }); + } catch (err) { + console.error(`[Order] Scriptalization failed for font ${font}:`, err.message); + return res.status(500).json({ + error: 'Scriptalization failed', + message: `Failed to scriptalize texts with font ${font}: ${err.message}` + }); + } + } + + // Step 4: Generate SVG files - separate letters and envelopes + const generatedLetters = []; + const generatedEnvelopes = []; + + for (let i = 0; i < processedLetters.length; i++) { + const letter = processedLetters[i]; + // Verwende loopIndex für scriptalizedMap Zuordnung + const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText; + + let svgContent; + + try { + if (letter.type === 'envelope') { + svgContent = generateEnvelopeSVG( + scriptalizedText, + letter.format, + letter.envelopeType, + { font: letter.font } + ); + } else { + svgContent = generateLetterSVG( + scriptalizedText, + letter.format, + { font: letter.font } + ); + } + } catch (err) { + console.error(`[Order] SVG generation failed for letter ${i}:`, err.message); + return res.status(500).json({ + error: 'SVG generation failed', + message: `Failed to generate SVG for letter ${i}: ${err.message}` + }); + } + + // Save SVG file - unterscheide zwischen Briefen und Umschlägen + const absoluteIndex = letter.index !== undefined ? letter.index : i; + + if (letter.type === 'envelope') { + const filename = `envelope_${String(absoluteIndex).padStart(3, '0')}.svg`; + const filepath = path.join(envelopesDir, filename); + await fs.writeFile(filepath, svgContent, 'utf8'); + generatedEnvelopes.push(filename); + } else { + const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`; + const filepath = path.join(outputDir, filename); + await fs.writeFile(filepath, svgContent, 'utf8'); + generatedLetters.push(filename); + } + } + + console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`); + + // Step 5: Generate all CSV files if placeholders are present + const hasPlaceholders = processedLetters.some(l => l.placeholders && Object.keys(l.placeholders).length > 0); + const csvFiles = []; + + if (hasPlaceholders) { + try { + const csvs = generateAllCSVs(processedLetters); + + // Brief-Platzhalter CSV + if (csvs.placeholders) { + const filename = 'brief_platzhalter.csv'; + await fs.writeFile(path.join(outputDir, filename), csvs.placeholders, 'utf8'); + csvFiles.push(filename); + console.log(`[Order] Generated brief_platzhalter.csv`); + } + + // Empfänger CSV (für recipientData-Modus) + if (csvs.recipients) { + const filename = 'empfaenger.csv'; + await fs.writeFile(path.join(outputDir, filename), csvs.recipients, 'utf8'); + csvFiles.push(filename); + console.log(`[Order] Generated empfaenger.csv`); + } + + // Umschlag-Platzhalter CSV (für customText-Modus) + if (csvs.envelopePlaceholders) { + const filename = 'umschlag_platzhalter.csv'; + await fs.writeFile(path.join(outputDir, filename), csvs.envelopePlaceholders, 'utf8'); + csvFiles.push(filename); + console.log(`[Order] Generated umschlag_platzhalter.csv`); + } + } catch (err) { + console.error('[Order] CSV generation failed:', err.message); + // Don't fail the request, CSV is optional + } + } + + // Step 6: Create order metadata + const orderMetadata = { + orderNumber, + generatedAt: new Date().toISOString(), + letterCount: generatedLetters.length, + envelopeCount: generatedEnvelopes.length, + letters: generatedLetters, + envelopes: generatedEnvelopes, + fonts: Object.keys(lettersByFont), + hasPlaceholders, + csvFiles + }; + + const orderMetadataPath = path.join(outputDir, 'order-metadata.json'); + await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8'); + + console.log(`[Order] Order ${orderNumber} generated successfully`); + + // Response + res.status(200).json({ + orderNumber, + outputPath: outputDir, + letters: generatedLetters, + envelopes: generatedEnvelopes, + csvFiles, + totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length, + timestamp: orderMetadata.generatedAt + }); + + } catch (err) { + console.error('[Order] Error generating order:', err); + next(err); + } +} + +/** + * POST /api/order/motif + * Upload a motif image for an order + */ +async function uploadMotif(req, res, next) { + try { + const { orderNumber } = req.body; + + // Validation + if (!orderNumber) { + return res.status(400).json({ + error: 'Invalid request', + message: 'orderNumber is required' + }); + } + + if (!req.file) { + return res.status(400).json({ + error: 'Invalid request', + message: 'No file uploaded' + }); + } + + console.log(`[Order] Uploading motif for order: ${orderNumber}`); + console.log(`[Order] File: ${req.file.originalname}, Size: ${req.file.size} bytes, Type: ${req.file.mimetype}`); + + // Create output directory if it doesn't exist + const outputDir = path.join(config.paths.output, orderNumber); + await fs.mkdir(outputDir, { recursive: true }); + + // Determine file extension + const originalExt = path.extname(req.file.originalname).toLowerCase() || '.png'; + const filename = `motif${originalExt}`; + const filepath = path.join(outputDir, filename); + + // Write file to disk + await fs.writeFile(filepath, req.file.buffer); + + console.log(`[Order] Motif saved to: ${filepath}`); + + // Response + res.status(200).json({ + success: true, + orderNumber, + filename, + path: filepath, + size: req.file.size, + mimetype: req.file.mimetype + }); + + } catch (err) { + console.error('[Order] Error uploading motif:', err); + next(err); + } +} + +module.exports = { + finalizeOrder, + generateOrder, + uploadMotif +}; diff --git a/Docker Backend/src/api/controllers/paypal-controller.js b/Docker Backend/src/api/controllers/paypal-controller.js new file mode 100644 index 0000000..0ebf732 --- /dev/null +++ b/Docker Backend/src/api/controllers/paypal-controller.js @@ -0,0 +1,200 @@ +const { + Client, + Environment, + LogLevel, + OrdersController +} = require('@paypal/paypal-server-sdk'); +const config = require('../../config'); + +// PayPal Client initialisieren +let client = null; +let ordersController = null; + +function initializePayPalClient() { + if (client) return; + + const { clientId, clientSecret, environment } = config.paypal; + + if (!clientId || !clientSecret) { + console.warn('[PayPal] Client ID oder Secret nicht konfiguriert'); + return; + } + + client = new Client({ + clientCredentialsAuthCredentials: { + oAuthClientId: clientId, + oAuthClientSecret: clientSecret, + }, + timeout: 0, + environment: environment === 'live' ? Environment.Production : Environment.Sandbox, + logging: { + logLevel: LogLevel.Info, + logRequest: { logBody: true }, + logResponse: { logHeaders: true }, + }, + }); + + ordersController = new OrdersController(client); + console.log(`[PayPal] Client initialisiert (${environment})`); +} + +// Initialisierung beim Laden des Moduls +initializePayPalClient(); + +/** + * Erstellt eine PayPal-Bestellung + * POST /api/paypal/orders + */ +async function createOrder(req, res) { + try { + if (!ordersController) { + return res.status(503).json({ + error: 'PayPal nicht konfiguriert', + message: 'PayPal Client ID und Secret müssen in den Umgebungsvariablen gesetzt sein' + }); + } + + const { amount, currency = 'EUR', orderData } = req.body; + + if (!amount || amount <= 0) { + return res.status(400).json({ + error: 'Ungültiger Betrag', + message: 'Der Bestellbetrag muss größer als 0 sein' + }); + } + + // Betrag auf 2 Dezimalstellen formatieren + const formattedAmount = parseFloat(amount).toFixed(2); + + // Produktbeschreibung erstellen + const productName = orderData?.product?.label || 'Skrift Handschriftservice'; + const quantity = orderData?.quantity || 1; + + const collect = { + body: { + intent: 'CAPTURE', + purchaseUnits: [ + { + amount: { + currencyCode: currency, + value: formattedAmount, + breakdown: { + itemTotal: { + currencyCode: currency, + value: formattedAmount, + }, + }, + }, + items: [ + { + name: productName, + unitAmount: { + currencyCode: currency, + value: formattedAmount, + }, + quantity: '1', + description: `${quantity}x ${productName}`, + sku: orderData?.product?.key || 'skrift-order', + }, + ], + }, + ], + paymentSource: { + paypal: { + experienceContext: { + userAction: 'PAY_NOW', + brandName: 'Skrift', + locale: 'de-DE', + landingPage: 'NO_PREFERENCE', + shippingPreference: 'NO_SHIPPING', + }, + }, + }, + }, + prefer: 'return=minimal', + }; + + console.log('[PayPal] Creating order:', { amount: formattedAmount, currency }); + + const { body, ...httpResponse } = await ordersController.createOrder(collect); + const jsonResponse = JSON.parse(body); + + console.log('[PayPal] Order created:', jsonResponse.id); + + res.status(httpResponse.statusCode).json(jsonResponse); + } catch (error) { + console.error('[PayPal] Create order error:', error.message); + res.status(500).json({ + error: 'Bestellung konnte nicht erstellt werden', + message: error.message + }); + } +} + +/** + * Erfasst eine PayPal-Zahlung + * POST /api/paypal/orders/:orderID/capture + */ +async function captureOrder(req, res) { + try { + if (!ordersController) { + return res.status(503).json({ + error: 'PayPal nicht konfiguriert', + message: 'PayPal Client ID und Secret müssen in den Umgebungsvariablen gesetzt sein' + }); + } + + const { orderID } = req.params; + + if (!orderID) { + return res.status(400).json({ + error: 'Order ID fehlt', + message: 'Die PayPal Order ID muss angegeben werden' + }); + } + + const collect = { + id: orderID, + prefer: 'return=minimal', + }; + + console.log('[PayPal] Capturing order:', orderID); + + const { body, ...httpResponse } = await ordersController.captureOrder(collect); + const jsonResponse = JSON.parse(body); + + console.log('[PayPal] Order captured:', { + id: jsonResponse.id, + status: jsonResponse.status + }); + + res.status(httpResponse.statusCode).json(jsonResponse); + } catch (error) { + console.error('[PayPal] Capture order error:', error.message); + res.status(500).json({ + error: 'Zahlung konnte nicht erfasst werden', + message: error.message + }); + } +} + +/** + * Prüft den PayPal-Konfigurationsstatus + * GET /api/paypal/status + */ +function getStatus(req, res) { + const { clientId, environment } = config.paypal; + + res.json({ + configured: !!(clientId && config.paypal.clientSecret), + environment, + // Nur die ersten/letzten Zeichen der Client ID anzeigen + clientIdPreview: clientId ? `${clientId.substring(0, 8)}...${clientId.substring(clientId.length - 4)}` : null + }); +} + +module.exports = { + createOrder, + captureOrder, + getStatus +}; diff --git a/Docker Backend/src/api/controllers/preview-controller.js b/Docker Backend/src/api/controllers/preview-controller.js new file mode 100644 index 0000000..8d7119b --- /dev/null +++ b/Docker Backend/src/api/controllers/preview-controller.js @@ -0,0 +1,322 @@ +const fs = require("fs").promises; +const fsSync = require("fs"); +const path = require("path"); +const { v4: uuidv4 } = require("uuid"); +const config = require("../../config"); +const { scriptalizeBatch } = require("../../services/scriptalizer-service"); +const { + generateLetterSVG, + generateEnvelopeSVG, +} = require("../../lib/svg-generator"); +const { + replacePlaceholders, + generateAllCSVs, +} = require("../../services/placeholder-service"); + +/** + * POST /api/preview/batch + * Generate preview batch with rate limiting + */ +async function generateBatch(req, res, next) { + try { + const { sessionId, letters } = req.body; + + // Validation + if (!letters || !Array.isArray(letters) || letters.length === 0) { + return res.status(400).json({ + error: "Invalid request", + message: "Letters array is required and must not be empty", + }); + } + + // KEIN Limit mehr - Frontend sendet ALLE Dokumente auf einmal + // Backend teilt sie intern in 25er Batches für Scriptalizer auf + console.log(`[Preview] Received ${letters.length} letters (no limit)`); + + // Optional: Warn if extremely large + if (letters.length > 1000) { + console.warn(`[Preview] Large batch detected: ${letters.length} letters`); + } + + // Generate or validate sessionId + const finalSessionId = sessionId || uuidv4(); + + console.log( + `[Preview] Starting batch generation for session: ${finalSessionId}` + ); + console.log(`[Preview] Batch size: ${letters.length} letters`); + + // Create session cache directory + const sessionDir = path.join(config.paths.previews, finalSessionId); + await fs.mkdir(sessionDir, { recursive: true }); + + // Step 1: Prepare texts and replace placeholders + console.log( + "[Preview] Received indices from frontend:", + letters.map((l) => l.index) + ); + + const processedLetters = letters.map((letter, loopIndex) => { + let text = letter.text || ""; + + // Replace placeholders if present + if (letter.placeholders && typeof letter.placeholders === "object") { + text = replacePlaceholders(text, letter.placeholders); + } + + return { + ...letter, + // Verwende den vom Frontend gesendeten Index, oder fallback auf loopIndex + index: letter.index !== undefined ? letter.index : loopIndex, + processedText: text, + format: letter.format || "a4", + font: letter.font || "tilda", + type: letter.type || "letter", + envelopeType: letter.envelopeType || "recipient", + }; + }); + + console.log( + "[Preview] Final indices being used for files:", + processedLetters.map((l) => l.index) + ); + + // Step 2: Batch scriptalize texts + const textsToScriptalize = processedLetters.map((l) => l.processedText); + const font = processedLetters[0]?.font || "tilda"; + + console.log( + `[Preview] Scriptalizing ${textsToScriptalize.length} texts with font: ${font}` + ); + + let scriptalizedTexts; + try { + scriptalizedTexts = await scriptalizeBatch( + textsToScriptalize, + font, + config.scriptalizer.errFrequency + ); + } catch (err) { + console.error("[Preview] Scriptalization failed:", err.message); + return res.status(500).json({ + error: "Scriptalization failed", + message: err.message, + }); + } + + if (scriptalizedTexts.length !== processedLetters.length) { + console.warn( + `[Preview] Scriptalization mismatch: expected ${processedLetters.length}, got ${scriptalizedTexts.length}` + ); + } + + // Step 3: Generate SVG files + const files = []; + let hasOverflow = false; + + for (let i = 0; i < processedLetters.length; i++) { + const letter = processedLetters[i]; + const scriptalizedText = scriptalizedTexts[i] || letter.processedText; + + let svgResult; + + try { + if (letter.type === "envelope") { + // Umschläge haben keine Zeilenbegrenzung - nur SVG zurückgeben + const svgContent = generateEnvelopeSVG( + scriptalizedText, + letter.format, + letter.envelopeType, + { font: letter.font } + ); + svgResult = { svg: svgContent, lineCount: 0, lineLimit: 0, overflow: false }; + } else { + // Briefe: Zeileninfo mit zurückgeben + svgResult = generateLetterSVG(scriptalizedText, letter.format, { + font: letter.font, + }); + } + } catch (err) { + console.error( + `[Preview] SVG generation failed for letter ${i}:`, + err.message + ); + return res.status(500).json({ + error: "SVG generation failed", + message: `Failed to generate SVG for letter ${i}: ${err.message}`, + }); + } + + // Track overflow + if (svgResult.overflow) { + hasOverflow = true; + console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`); + } + + // Save SVG file - verwende den absoluten Index aus letter + // Unterscheide zwischen Briefen und Umschlägen im Dateinamen + const absoluteIndex = letter.index !== undefined ? letter.index : i; + const prefix = letter.type === 'envelope' ? 'envelope' : 'letter'; + const filename = `${prefix}_${String(absoluteIndex).padStart(3, "0")}.svg`; + const filepath = path.join(sessionDir, filename); + + await fs.writeFile(filepath, svgResult.svg, "utf8"); + + // Empfänger-Info für bessere Fehlermeldung + const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`; + + files.push({ + index: absoluteIndex, + filename, + url: `/api/preview/${finalSessionId}/${filename}`, + lineCount: svgResult.lineCount, + lineLimit: svgResult.lineLimit, + overflow: svgResult.overflow, + recipientName: letter.type !== 'envelope' ? recipientName : null, + }); + } + + console.log(`[Preview] Generated ${files.length} SVG files`); + + // Step 4: Generate all CSV files (placeholders, recipients, envelope placeholders, free addresses) + const csvUrls = {}; + const hasPlaceholders = processedLetters.some((l) => l.placeholders && Object.keys(l.placeholders).length > 0); + const hasFreeAddresses = processedLetters.some((l) => l.type === 'envelope' && l.envelopeType === 'free'); + + if (hasPlaceholders || hasFreeAddresses) { + try { + const csvs = generateAllCSVs(processedLetters); + + // Brief-Platzhalter CSV + if (csvs.placeholders) { + const filename = "brief_platzhalter.csv"; + await fs.writeFile(path.join(sessionDir, filename), csvs.placeholders, "utf8"); + csvUrls.placeholders = `/api/preview/${finalSessionId}/${filename}`; + console.log(`[Preview] Generated brief_platzhalter.csv`); + } + + // Empfänger CSV (für recipientData-Modus) + if (csvs.recipients) { + const filename = "empfaenger.csv"; + await fs.writeFile(path.join(sessionDir, filename), csvs.recipients, "utf8"); + csvUrls.recipients = `/api/preview/${finalSessionId}/${filename}`; + console.log(`[Preview] Generated empfaenger.csv`); + } + + // Umschlag-Platzhalter CSV (für customText-Modus) + if (csvs.envelopePlaceholders) { + const filename = "umschlag_platzhalter.csv"; + await fs.writeFile(path.join(sessionDir, filename), csvs.envelopePlaceholders, "utf8"); + csvUrls.envelopePlaceholders = `/api/preview/${finalSessionId}/${filename}`; + console.log(`[Preview] Generated umschlag_platzhalter.csv`); + } + + // Freie Adressen CSV (für free-Modus) + if (csvs.freeAddresses) { + const filename = "freie_adressen.csv"; + await fs.writeFile(path.join(sessionDir, filename), csvs.freeAddresses, "utf8"); + csvUrls.freeAddresses = `/api/preview/${finalSessionId}/${filename}`; + console.log(`[Preview] Generated freie_adressen.csv`); + } + } catch (err) { + console.error("[Preview] CSV generation failed:", err.message); + // Don't fail the request, CSV is optional + } + } + + // Step 5: Save session metadata (no expiration) + const metadataPath = path.join(sessionDir, ".metadata.json"); + const metadata = { + sessionId: finalSessionId, + createdAt: new Date().toISOString(), + letterCount: files.length, + font, + hasPlaceholders, + }; + + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), "utf8"); + + console.log( + `[Preview] Batch generation complete for session: ${finalSessionId}` + ); + + // Response + res.status(200).json({ + sessionId: finalSessionId, + files, + csvUrls, + hasOverflow, + overflowFiles: files.filter(f => f.overflow), + }); + } catch (err) { + console.error("[Preview] Unexpected error:", err); + next(err); + } +} + +/** + * GET /api/preview/:sessionId/:filename + * Serve cached preview files + */ +async function servePreview(req, res, next) { + try { + const { sessionId, filename } = req.params; + + // Validate inputs + if (!sessionId || !filename) { + return res.status(400).json({ + error: "Invalid request", + message: "sessionId and filename are required", + }); + } + + // Prevent directory traversal + const sanitizedFilename = path.basename(filename); + const filePath = path.join( + config.paths.previews, + sessionId, + sanitizedFilename + ); + + // Check if file exists + try { + await fs.access(filePath); + } catch { + return res.status(404).json({ + error: "File not found", + message: `Preview file not found: ${sanitizedFilename}`, + }); + } + + // No cache expiration check - files are served until manually deleted + + // Determine content type + const ext = path.extname(sanitizedFilename).toLowerCase(); + let contentType = "application/octet-stream"; + + if (ext === ".svg") { + contentType = "image/svg+xml"; + } else if (ext === ".csv") { + contentType = "text/csv"; + } + + // Serve file - NO CACHING + res.setHeader("Content-Type", contentType); + res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + + const fileContent = await fs.readFile(filePath); + res.send(fileContent); + } catch (err) { + console.error("[Preview] Error serving file:", err); + next(err); + } +} + +// No automatic cache cleanup - files are kept until manually deleted + +module.exports = { + generateBatch, + servePreview, +}; diff --git a/Docker Backend/src/api/middleware/auth.js b/Docker Backend/src/api/middleware/auth.js new file mode 100644 index 0000000..0c9f8fd --- /dev/null +++ b/Docker Backend/src/api/middleware/auth.js @@ -0,0 +1,72 @@ +/** + * Authentication Middleware + * Validiert API-Token in Request-Headers + */ + +const config = require('../../config'); + +/** + * Middleware: API-Token validieren + */ +function authenticateApiToken(req, res, next) { + // API-Token aus Umgebungsvariablen + const validToken = config.auth.apiToken; + + // Wenn kein Token konfiguriert ist, alle Requests erlauben (Development) + if (!validToken) { + console.warn('[Auth] WARNING: No API token configured - authentication disabled!'); + return next(); + } + + // Token aus verschiedenen Quellen prüfen + const token = + req.headers['x-api-token'] || // Custom Header + req.headers['authorization']?.replace('Bearer ', '') || // Bearer Token + req.query.api_token; // Query Parameter (nur für Tests) + + // Kein Token vorhanden + if (!token) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'API token required. Please provide X-API-Token header or Authorization: Bearer ', + }); + } + + // Token validieren + if (token !== validToken) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Invalid API token', + }); + } + + // Token ist gültig + next(); +} + +/** + * Optionale Authentifizierung - Warnung aber kein Fehler + */ +function optionalAuth(req, res, next) { + const validToken = config.auth.apiToken; + + if (!validToken) { + return next(); + } + + const token = + req.headers['x-api-token'] || + req.headers['authorization']?.replace('Bearer ', '') || + req.query.api_token; + + if (!token || token !== validToken) { + console.warn('[Auth] Unauthenticated request to', req.path); + } + + next(); +} + +module.exports = { + authenticateApiToken, + optionalAuth, +}; diff --git a/Docker Backend/src/api/middleware/error-handler.js b/Docker Backend/src/api/middleware/error-handler.js new file mode 100644 index 0000000..20bb20e --- /dev/null +++ b/Docker Backend/src/api/middleware/error-handler.js @@ -0,0 +1,13 @@ +module.exports = (err, req, res, next) => { + console.error('Error occurred:', err); + + const statusCode = err.statusCode || 500; + const message = err.message || 'Internal Server Error'; + + res.status(statusCode).json({ + error: { + message, + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) + } + }); +}; diff --git a/Docker Backend/src/api/middleware/rate-limiter.js b/Docker Backend/src/api/middleware/rate-limiter.js new file mode 100644 index 0000000..f91db85 --- /dev/null +++ b/Docker Backend/src/api/middleware/rate-limiter.js @@ -0,0 +1,53 @@ +const config = require('../../config'); + +// Simple in-memory rate limiter +const rateLimitStore = new Map(); + +// Cleanup old entries every minute +setInterval(() => { + const now = Date.now(); + const oneMinute = 60 * 1000; + + for (const [key, data] of rateLimitStore.entries()) { + if (now - data.windowStart > oneMinute) { + rateLimitStore.delete(key); + } + } +}, 60 * 1000); + +module.exports = (req, res, next) => { + const sessionId = req.body.sessionId || req.params.sessionId || 'anonymous'; + const now = Date.now(); + const oneMinute = 60 * 1000; + + let rateData = rateLimitStore.get(sessionId); + + if (!rateData || now - rateData.windowStart > oneMinute) { + // New window + rateData = { + windowStart: now, + requests: [] + }; + rateLimitStore.set(sessionId, rateData); + } + + // Remove requests older than 1 minute + rateData.requests = rateData.requests.filter(timestamp => now - timestamp < oneMinute); + + // Check limit + if (rateData.requests.length >= config.preview.rateLimitPerMinute) { + const oldestRequest = Math.min(...rateData.requests); + const retryAfter = Math.ceil((oneMinute - (now - oldestRequest)) / 1000); + + return res.status(429).json({ + error: 'Zu viele Vorschau-Anfragen. Bitte warten Sie.', + retryAfter, + message: `Limit: ${config.preview.rateLimitPerMinute} Anfragen pro Minute` + }); + } + + // Add current request + rateData.requests.push(now); + + next(); +}; diff --git a/Docker Backend/src/api/middleware/request-logger.js b/Docker Backend/src/api/middleware/request-logger.js new file mode 100644 index 0000000..1fbf736 --- /dev/null +++ b/Docker Backend/src/api/middleware/request-logger.js @@ -0,0 +1,14 @@ +module.exports = (req, res, next) => { + const start = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - start; + const timestamp = new Date().toISOString(); + + console.log( + `[${timestamp}] ${req.method} ${req.path} - ${res.statusCode} (${duration}ms)` + ); + }); + + next(); +}; diff --git a/Docker Backend/src/api/routes/health-routes.js b/Docker Backend/src/api/routes/health-routes.js new file mode 100644 index 0000000..6192eda --- /dev/null +++ b/Docker Backend/src/api/routes/health-routes.js @@ -0,0 +1,34 @@ +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const config = require('../../config'); + +router.get('/', (req, res) => { + const health = { + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + scriptalizer: config.scriptalizer.licenseKey ? 'configured' : 'missing', + storage: { + cache: fs.existsSync(config.paths.cache) && isWritable(config.paths.cache), + output: fs.existsSync(config.paths.output) && isWritable(config.paths.output) + } + }; + + const allHealthy = health.scriptalizer === 'configured' && + health.storage.cache && + health.storage.output; + + res.status(allHealthy ? 200 : 503).json(health); +}); + +function isWritable(path) { + try { + fs.accessSync(path, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +module.exports = router; diff --git a/Docker Backend/src/api/routes/order-routes.js b/Docker Backend/src/api/routes/order-routes.js new file mode 100644 index 0000000..0807b5f --- /dev/null +++ b/Docker Backend/src/api/routes/order-routes.js @@ -0,0 +1,94 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const orderController = require('../controllers/order-controller'); +const { authenticateApiToken } = require('../middleware/auth'); + +// Multer konfigurieren für Datei-Uploads (temporär im Speicher) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB max + }, + fileFilter: (req, file, cb) => { + // Erlaubte Dateitypen + const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'application/pdf']; + const allowedExts = ['.png', '.jpg', '.jpeg', '.webp', '.svg', '.pdf']; + + const ext = file.originalname.toLowerCase().substring(file.originalname.lastIndexOf('.')); + + if (allowedTypes.includes(file.mimetype) || allowedExts.includes(ext)) { + cb(null, true); + } else { + cb(new Error('Nur PNG, JPG, WEBP, SVG und PDF Dateien sind erlaubt'), false); + } + } +}); + +/** + * POST /api/order/finalize + * Finalize order by copying cached previews to output directory + * + * Request body: + * { + * sessionId: string, + * orderNumber: string + * } + * + * Response: + * { + * orderNumber: string, + * outputPath: string, + * files: string[], + * timestamp: string + * } + */ +router.post('/finalize', authenticateApiToken, orderController.finalizeOrder); + +/** + * POST /api/order/generate + * Generate order from scratch without using cache + * + * Request body: + * { + * orderNumber: string, + * letters: [ + * { + * text: string, + * format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang', + * font: 'tilda' | 'alva' | 'ellie', + * type?: 'letter' | 'envelope', + * envelopeType?: 'recipient' | 'custom', + * placeholders?: { [key: string]: string } + * } + * ] + * } + * + * Response: + * { + * orderNumber: string, + * outputPath: string, + * files: string[], + * timestamp: string + * } + */ +router.post('/generate', authenticateApiToken, orderController.generateOrder); + +/** + * POST /api/order/motif + * Upload a motif image for an order + * + * Request: multipart/form-data + * - motif: file (PNG, JPG, WEBP, SVG, PDF) + * - orderNumber: string + * + * Response: + * { + * success: boolean, + * filename: string, + * path: string + * } + */ +router.post('/motif', authenticateApiToken, upload.single('motif'), orderController.uploadMotif); + +module.exports = router; diff --git a/Docker Backend/src/api/routes/paypal-routes.js b/Docker Backend/src/api/routes/paypal-routes.js new file mode 100644 index 0000000..acff8f5 --- /dev/null +++ b/Docker Backend/src/api/routes/paypal-routes.js @@ -0,0 +1,14 @@ +const express = require('express'); +const router = express.Router(); +const paypalController = require('../controllers/paypal-controller'); + +// PayPal-Konfigurationsstatus +router.get('/status', paypalController.getStatus); + +// Bestellung erstellen +router.post('/orders', paypalController.createOrder); + +// Zahlung erfassen +router.post('/orders/:orderID/capture', paypalController.captureOrder); + +module.exports = router; diff --git a/Docker Backend/src/api/routes/preview-routes.js b/Docker Backend/src/api/routes/preview-routes.js new file mode 100644 index 0000000..0b98dc4 --- /dev/null +++ b/Docker Backend/src/api/routes/preview-routes.js @@ -0,0 +1,47 @@ +const express = require('express'); +const router = express.Router(); +const previewController = require('../controllers/preview-controller'); +const { authenticateApiToken } = require('../middleware/auth'); + +/** + * POST /api/preview/batch + * Generate preview batch (no rate limiting, no batch size limit) + * Backend automatically splits into 25-letter batches for Scriptalizer API + * + * Request body: + * { + * sessionId: string, + * letters: [ + * { + * text: string, + * format: 'a4' | 'a6p' | 'a6l' | 'c6' | 'din_lang', + * font: 'tilda' | 'alva' | 'ellie', + * type?: 'letter' | 'envelope', + * envelopeType?: 'recipient' | 'custom', + * placeholders?: { [key: string]: string } + * } + * ] + * } + * + * Response: + * { + * sessionId: string, + * files: [ + * { + * index: number, + * filename: string, + * url: string + * } + * ], + * csvUrl?: string + * } + */ +router.post('/batch', authenticateApiToken, previewController.generateBatch); + +/** + * GET /api/preview/:sessionId/:filename + * Serve cached preview SVG files + */ +router.get('/:sessionId/:filename', previewController.servePreview); + +module.exports = router; diff --git a/Docker Backend/src/config/index.js b/Docker Backend/src/config/index.js new file mode 100644 index 0000000..717554e --- /dev/null +++ b/Docker Backend/src/config/index.js @@ -0,0 +1,53 @@ +require('dotenv').config(); +const path = require('path'); + +const isProduction = process.env.NODE_ENV === 'production'; + +module.exports = { + env: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT, 10) || 4000, + + scriptalizer: { + licenseKey: process.env.SCRIPTALIZER_LICENSE_KEY, + errFrequency: parseInt(process.env.SCRIPTALIZER_ERR_FREQUENCY, 10) || 0, + endpoint: 'https://www.scriptalizer.co.uk/QuantumScriptalize.asmx/Scriptalize', + fontMap: { + tilda: 'PremiumUltra79', + alva: 'PremiumUltra23', + ellie: 'PremiumUltra39' + }, + separator: '|||', // Triple pipe separator (tested and working) + maxInputSize: 48000 // 48KB limit + }, + + preview: { + // No batch size limit - frontend can send any number of letters + // Backend splits into 25-letter batches for Scriptalizer API internally + scriptalizerBatchSize: 25 + // No cache lifetime - files are kept until manually cleaned + // No rate limiting + }, + + paths: { + cache: isProduction ? '/app/cache' : path.join(__dirname, '../../cache'), + previews: isProduction ? '/app/cache/previews' : path.join(__dirname, '../../cache/previews'), + output: isProduction ? '/app/output' : path.join(__dirname, '../../output'), + fonts: isProduction ? '/app/fonts' : path.join(__dirname, '../../fonts') + }, + + cors: { + origin: process.env.CORS_ORIGIN || '*', + credentials: true + }, + + auth: { + apiToken: process.env.API_TOKEN || null + }, + + paypal: { + clientId: process.env.PAYPAL_CLIENT_ID || '', + clientSecret: process.env.PAYPAL_CLIENT_SECRET || '', + // 'sandbox' oder 'live' + environment: process.env.PAYPAL_ENVIRONMENT || 'sandbox' + } +}; diff --git a/Docker Backend/src/lib/page-layout.js b/Docker Backend/src/lib/page-layout.js new file mode 100644 index 0000000..fe36106 --- /dev/null +++ b/Docker Backend/src/lib/page-layout.js @@ -0,0 +1,122 @@ +const PX_PER_MM = 3.78; // 96dpi +const FONT_SIZE_PX = 26; + +// Maximale Zeilenanzahl pro Format (für Textvalidierung) +const LINE_LIMITS = { + a4: 27, + a6p: 14, + a6l: 9, +}; + +// Layout Konfiguration pro Format + Orientation +const FORMAT_LAYOUTS = { + // A4 Hochformat + a4: { + marginTopMm: 25, + marginLeftMm: 20, + lineHeightFactor: 1.35, + }, + + // A6 Hochformat + a6p: { + marginTopMm: 12, + marginLeftMm: 10, + lineHeightFactor: 1.3, + }, + + // A6 Querformat (landscape) + a6l: { + marginTopMm: 10, + marginLeftMm: 8, + lineHeightFactor: 1.2, + }, + + // DIN Lang Kuvert (Querformat) + din_lang: { + marginTopMm: 50, + marginLeftMm: 10, // Links unten in Ecke + lineHeightFactor: 1.2, + }, + + // C6 Kuvert (Querformat) + c6: { + marginTopMm: 60, + marginLeftMm: 10, // Links unten in Ecke + lineHeightFactor: 1.2, + }, +}; + +// Seiten-Dimensionen +const PAGE_FORMATS = { + a4: { widthMm: 210, heightMm: 297 }, + a6p: { widthMm: 105, heightMm: 148 }, + a6l: { widthMm: 148, heightMm: 105 }, // Querformat: getauscht + din_lang: { widthMm: 220, heightMm: 110 }, + c6: { widthMm: 162, heightMm: 114 }, +}; + +// mm in Pixel umrechnen +function mmToPx(mm) { + return mm * PX_PER_MM; +} + +// Layout für Format holen +function getLayoutForFormat(format) { + const normalizedFormat = String(format).toLowerCase(); + const layoutConfig = FORMAT_LAYOUTS[normalizedFormat]; + + if (!layoutConfig) { + // Fallback auf a4 + return { + marginTopPx: mmToPx(FORMAT_LAYOUTS.a4.marginTopMm), + marginLeftPx: mmToPx(FORMAT_LAYOUTS.a4.marginLeftMm), + lineHeightPx: FONT_SIZE_PX * FORMAT_LAYOUTS.a4.lineHeightFactor, + }; + } + + return { + marginTopPx: mmToPx(layoutConfig.marginTopMm), + marginLeftPx: mmToPx(layoutConfig.marginLeftMm), + lineHeightPx: FONT_SIZE_PX * layoutConfig.lineHeightFactor, + }; +} + +// Seiten-Dimensionen holen +function getPageDimensions(format) { + const normalizedFormat = String(format).toLowerCase(); + const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4; + + return { + widthMm: dimensions.widthMm, + heightMm: dimensions.heightMm, + widthPx: mmToPx(dimensions.widthMm), + heightPx: mmToPx(dimensions.heightMm), + }; +} + +// XML Escaping +function escapeXml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +// Zeilenlimit für Format holen +function getLineLimit(format) { + const normalizedFormat = String(format).toLowerCase(); + return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4; +} + +module.exports = { + PX_PER_MM, + FONT_SIZE_PX, + FORMAT_LAYOUTS, + PAGE_FORMATS, + LINE_LIMITS, + mmToPx, + getLayoutForFormat, + getPageDimensions, + getLineLimit, + escapeXml +}; diff --git a/Docker Backend/src/lib/svg-font-engine.js b/Docker Backend/src/lib/svg-font-engine.js new file mode 100644 index 0000000..ac20282 --- /dev/null +++ b/Docker Backend/src/lib/svg-font-engine.js @@ -0,0 +1,212 @@ +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +// Cache je Fontdatei +const FONT_CACHE = {}; + +// Attribut aus einem Tag lesen (mit Wortgrenze, um z.B. 'd' vs 'id' zu unterscheiden) +function getAttr(tag, name) { + const re = new RegExp(`\\b${name}="([^"]*)"`); + const m = tag.match(re); + return m ? m[1] : null; +} + +// SVG-Font parsen und Metriken plus Glyphen extrahieren +function parseSvgFont(fontFileName) { + if (FONT_CACHE[fontFileName]) return FONT_CACHE[fontFileName]; + + const fontPath = path.join(config.paths.fonts, fontFileName); + const svg = fs.readFileSync(fontPath, 'utf8'); + + const fontFaceMatch = svg.match(//); + if (!fontFaceMatch) { + throw new Error(`Kein in ${fontFileName} gefunden`); + } + + const fontFaceTag = fontFaceMatch[0]; + const unitsPerEm = parseFloat(getAttr(fontFaceTag, 'units-per-em') || '1000'); + const ascent = parseFloat(getAttr(fontFaceTag, 'ascent') || '0'); + const descent = parseFloat(getAttr(fontFaceTag, 'descent') || '0'); + + const glyphRegex = //g; + const glyphs = {}; + let m; + + while ((m = glyphRegex.exec(svg)) !== null) { + const tag = m[0]; + const unicode = getAttr(tag, 'unicode'); + const d = getAttr(tag, 'd'); + const adv = parseFloat(getAttr(tag, 'horiz-adv-x') || '0'); + + if (!unicode || !d) continue; + + glyphs[unicode] = { + d, + adv, + }; + } + + const fontData = { + unitsPerEm, + ascent, + descent, + glyphs, + }; + + FONT_CACHE[fontFileName] = fontData; + return fontData; +} + +/** + * Breite einer Textzeile in Pixeln messen. + */ +function measureTextWidthPx({ text, fontFileName, fontSizePx }) { + const { unitsPerEm, glyphs } = parseSvgFont(fontFileName); + const scale = fontSizePx / unitsPerEm; + + let x = 0; + + for (const ch of String(text || '')) { + const glyph = glyphs[ch]; + + if (!glyph) { + // Fallback für Leerzeichen / unbekannte Zeichen + const spacePx = fontSizePx * 0.4; + x += spacePx; + continue; + } + + const advPx = (glyph.adv || unitsPerEm * 0.5) * scale; + x += advPx; + } + + return x; +} + +/** + * Text nach verfügbarer Breite umbrechen. + * Garantiert: keine Zeile wird breiter als maxWidthPx. + */ +function wrapTextToLinesByWidth({ + text, + maxWidthPx, + fontFileName, + fontSizePx, +}) { + const paragraphs = String(text || '').split(/\r?\n/); + const allLines = []; + + const measure = (t) => + measureTextWidthPx({ text: t, fontFileName, fontSizePx }); + + for (const para of paragraphs) { + const words = para.split(/\s+/).filter(Boolean); + if (words.length === 0) { + allLines.push(''); + continue; + } + + let currentLine = ''; + + for (const word of words) { + // Wort alleine zu breit → Zeichenweise brechen + if (measure(word) > maxWidthPx) { + if (currentLine) { + allLines.push(currentLine); + currentLine = ''; + } + + let buf = ''; + for (const ch of word) { + const candidate = buf + ch; + if (measure(candidate) > maxWidthPx && buf) { + allLines.push(buf); + buf = ch; + } else { + buf = candidate; + } + } + if (buf) currentLine = buf; + continue; + } + + if (!currentLine) { + currentLine = word; + continue; + } + + const candidate = `${currentLine} ${word}`; + if (measure(candidate) <= maxWidthPx) { + currentLine = candidate; + } else { + allLines.push(currentLine); + currentLine = word; + } + } + + if (currentLine) { + allLines.push(currentLine); + } + } + + return allLines; +} + +/** + * Textzeilen in Pfade mit Transform-Matrix umwandeln. + */ +function layoutTextToPaths({ + lines, + fontFileName, + fontSizePx, + startX, + startY, + lineHeightPx, +}) { + const { unitsPerEm, glyphs } = parseSvgFont(fontFileName); + + const scale = fontSizePx / unitsPerEm; + + const resultPaths = []; + let baselineY = startY; + + for (const line of lines) { + let x = startX; + + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + const glyph = glyphs[ch]; + + if (!glyph) { + // Leerzeichen mit festem Abstand + const spacePx = fontSizePx * 0.4; + x += spacePx; + continue; + } + + const d = glyph.d; + const advPx = (glyph.adv || unitsPerEm * 0.5) * scale; + + // Einfache Transform-Matrix ohne Rotation + const transform = `matrix(${scale},0,0,${-scale},${x},${baselineY})`; + + resultPaths.push({ + d, + transform, + }); + + x += advPx; + } + + baselineY += lineHeightPx; + } + + return resultPaths; +} + +module.exports = { + measureTextWidthPx, + wrapTextToLinesByWidth, + layoutTextToPaths +}; diff --git a/Docker Backend/src/lib/svg-generator.js b/Docker Backend/src/lib/svg-generator.js new file mode 100644 index 0000000..eeb653d --- /dev/null +++ b/Docker Backend/src/lib/svg-generator.js @@ -0,0 +1,213 @@ +const { + FONT_SIZE_PX, + getLayoutForFormat, + getPageDimensions, + getLineLimit, + escapeXml +} = require('./page-layout'); + +const { + layoutTextToPaths, + wrapTextToLinesByWidth, + measureTextWidthPx +} = require('./svg-font-engine'); + +// Mapping der Handschriften auf SVG-Font-Dateien +const SVG_FONT_CONFIG = { + tilda: { file: 'tilda.svg' }, + alva: { file: 'alva.svg' }, + ellie: { file: 'ellie.svg' }, +}; + +function normalizeFontKey(font) { + if (!font) return 'tilda'; + const f = String(font).toLowerCase(); + if (['tilda', 'alva', 'ellie'].includes(f)) return f; + return 'tilda'; +} + +/** + * Generiert ein Schriftstück (Brief/Postkarte) als SVG + * @param {string} text - Der scriptalisierte Text + * @param {string} format - Format: a4, a6p, a6l + * @param {object} options - { font: 'tilda', alignment: 'left' } + */ +function generateLetterSVG(text, format, options = {}) { + const fontKey = normalizeFontKey(options.font); + const fontConfig = SVG_FONT_CONFIG[fontKey]; + + const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format); + const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format); + + // Automatischer Zeilenumbruch + const maxWidthPx = widthPx - 2 * marginLeftPx; + + const lines = wrapTextToLinesByWidth({ + text: String(text || ''), + maxWidthPx, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + }); + + const firstBaselineY = marginTopPx + FONT_SIZE_PX; + + const paths = layoutTextToPaths({ + lines, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + startX: marginLeftPx, + startY: firstBaselineY, + lineHeightPx, + }); + + const pathElements = paths + .map( + (p) => `` + ) + .join('\n '); + + const svg = ` + + ${pathElements} +`; + + const lineLimit = getLineLimit(format); + const lineCount = lines.length; + + return { + svg: svg.trim(), + lineCount, + lineLimit, + overflow: lineCount > lineLimit + }; +} + +/** + * Generiert einen Umschlag als SVG + * @param {string} text - Der scriptalisierte Text + * @param {string} format - Format: c6, din_lang + * @param {string} type - Typ: 'recipient' (links), 'free' (links, freie Adresse), 'custom' (mittig) + * @param {object} options - { font: 'tilda' } + */ +function generateEnvelopeSVG(text, format, type, options = {}) { + const fontKey = normalizeFontKey(options.font); + const fontConfig = SVG_FONT_CONFIG[fontKey]; + + const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format); + const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format); + + let lines; + let startX; + let startY; + + if (type === 'custom') { + // Mittig zentriert, 70% der Umschlagsbreite + const maxWidthPx = widthPx * 0.7; + + lines = wrapTextToLinesByWidth({ + text: String(text || ''), + maxWidthPx, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + }); + + // Zentrierung berechnen + const totalTextHeight = lines.length * lineHeightPx; + startY = (heightPx - totalTextHeight) / 2 + FONT_SIZE_PX; + + // Jede Zeile einzeln zentrieren + // startX wird später pro Zeile berechnet + startX = 0; // Dummy-Wert, wird für jede Zeile überschrieben + + } else { + // type === 'recipient': Links unten in Ecke + const maxWidthPx = widthPx - marginLeftPx - 20; + + lines = wrapTextToLinesByWidth({ + text: String(text || ''), + maxWidthPx, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + }); + + startX = marginLeftPx; + startY = marginTopPx + FONT_SIZE_PX; + } + + let paths = []; + + if (type === 'custom') { + // Bei custom type: Jede Zeile einzeln zentrieren + let currentY = startY; + for (const line of lines) { + const lineWidth = measureTextWidthPx({ + text: line, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX + }); + const centeredX = (widthPx - lineWidth) / 2; + + const linePaths = layoutTextToPaths({ + lines: [line], + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + startX: centeredX, + startY: currentY, + lineHeightPx, + }); + + paths.push(...linePaths); + currentY += lineHeightPx; + } + } else { + // Bei recipient type: Normal links ausrichten + paths = layoutTextToPaths({ + lines, + fontFileName: fontConfig.file, + fontSizePx: FONT_SIZE_PX, + startX, + startY, + lineHeightPx, + }); + } + + const pathElements = paths + .map( + (p) => `` + ) + .join('\n '); + + const svg = ` + + ${pathElements} +`; + + return svg.trim(); +} + +module.exports = { + generateLetterSVG, + generateEnvelopeSVG +}; diff --git a/Docker Backend/src/server.js b/Docker Backend/src/server.js new file mode 100644 index 0000000..0dbeee1 --- /dev/null +++ b/Docker Backend/src/server.js @@ -0,0 +1,55 @@ +const express = require('express'); +const cors = require('cors'); +const config = require('./config'); + +const previewRoutes = require('./api/routes/preview-routes'); +const orderRoutes = require('./api/routes/order-routes'); +const healthRoutes = require('./api/routes/health-routes'); +const paypalRoutes = require('./api/routes/paypal-routes'); + +const errorHandler = require('./api/middleware/error-handler'); +const requestLogger = require('./api/middleware/request-logger'); + +const app = express(); + +// Middleware +app.use(cors(config.cors)); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(requestLogger); + +// Routes +app.use('/health', healthRoutes); +app.use('/api/preview', previewRoutes); +app.use('/api/order', orderRoutes); +app.use('/api/paypal', paypalRoutes); + +// Error handling +app.use(errorHandler); + +// Start server +app.listen(config.port, () => { + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ Skrift Backend Server ║'); + console.log('╚════════════════════════════════════════════════════════════╝'); + console.log(`Environment: ${config.env}`); + console.log(`Port: ${config.port}`); + console.log(`Batch Size: Unlimited (auto-split to ${config.preview.scriptalizerBatchSize} for API)`); + console.log(`Cache: Disabled`); + console.log(`Rate Limit: Disabled`); + console.log(`Scriptalizer: ${config.scriptalizer.licenseKey ? 'Configured ✓' : 'Missing ✗'}`); + console.log('════════════════════════════════════════════════════════════'); + console.log(`Server running at http://localhost:${config.port}`); + console.log('════════════════════════════════════════════════════════════\n'); +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully...'); + process.exit(0); +}); + +process.on('SIGINT', () => { + console.log('\nSIGINT received, shutting down gracefully...'); + process.exit(0); +}); diff --git a/Docker Backend/src/services/placeholder-service.js b/Docker Backend/src/services/placeholder-service.js new file mode 100644 index 0000000..b01adac --- /dev/null +++ b/Docker Backend/src/services/placeholder-service.js @@ -0,0 +1,283 @@ +/** + * Ersetzt Platzhalter im Text + * @param {string} text - Text mit Platzhaltern wie [[Vorname]] + * @param {object} placeholders - Objekt mit Platzhalter-Werten {Vorname: 'Max', ...} + * @returns {string} - Text mit ersetzten Platzhaltern + */ +function replacePlaceholders(text, placeholders) { + if (!placeholders || typeof placeholders !== 'object') { + return text; + } + + let result = text; + + for (const [key, value] of Object.entries(placeholders)) { + const placeholder = `[[${key}]]`; + // Case-insensitive replacement: 'i' flag + const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + result = result.replace(regex, value || ''); + } + + return result; +} + +/** + * Escaped einen Wert für CSV + */ +function escapeCSV(value) { + const str = String(value || ''); + if (str.includes(',') || str.includes('\n') || str.includes('"')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +/** + * Generiert CSV aus Platzhalter-Daten (für Briefe) + * @param {Array} letters - Array von Letter-Objekten mit placeholders + * @returns {string} - CSV-String + */ +function generatePlaceholderCSV(letters) { + if (!Array.isArray(letters) || letters.length === 0) { + return ''; + } + + // Nur Briefe filtern (keine Umschläge) + const letterItems = letters.filter(l => l.type !== 'envelope'); + if (letterItems.length === 0) { + return ''; + } + + // Sammle alle unique Keys + const allKeys = new Set(); + letterItems.forEach(letter => { + if (letter.placeholders) { + Object.keys(letter.placeholders).forEach(key => allKeys.add(key)); + } + }); + + if (allKeys.size === 0) { + return ''; + } + + const keys = ['BriefNr', ...Array.from(allKeys).sort()]; + + // Header + const header = keys.join(','); + + // Rows + const rows = letterItems.map((letter, index) => { + const briefNr = String(letter.index !== undefined ? letter.index : index).padStart(3, '0'); + const values = [briefNr]; + + for (let i = 1; i < keys.length; i++) { + const key = keys[i]; + const value = letter.placeholders?.[key] || ''; + values.push(escapeCSV(value)); + } + + return values.join(','); + }); + + return [header, ...rows].join('\n'); +} + +/** + * Generiert CSV für Empfängerdaten (Umschläge im recipientData-Modus) + * @param {Array} letters - Array von Letter-Objekten + * @returns {string} - CSV-String + */ +function generateRecipientCSV(letters) { + if (!Array.isArray(letters) || letters.length === 0) { + return ''; + } + + // Nur Umschläge im recipient-Modus filtern + const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'recipient'); + if (envelopes.length === 0) { + return ''; + } + + // Prüfe ob Empfängerdaten vorhanden sind (vorname, name, strasse, etc.) + const recipientKeys = ['vorname', 'name', 'strasse', 'hausnummer', 'plz', 'ort', 'land']; + const hasRecipientData = envelopes.some(env => + env.placeholders && recipientKeys.some(key => env.placeholders[key]) + ); + + if (!hasRecipientData) { + return ''; + } + + const keys = ['UmschlagNr', ...recipientKeys]; + const header = keys.join(','); + + const rows = envelopes.map((envelope, index) => { + const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0'); + const values = [umschlagNr]; + + for (let i = 1; i < keys.length; i++) { + const key = keys[i]; + const value = envelope.placeholders?.[key] || ''; + values.push(escapeCSV(value)); + } + + return values.join(','); + }); + + return [header, ...rows].join('\n'); +} + +/** + * Generiert CSV für freie Adressen (Umschläge im free-Modus) + * @param {Array} letters - Array von Letter-Objekten + * @returns {string} - CSV-String + */ +function generateFreeAddressCSV(letters) { + if (!Array.isArray(letters) || letters.length === 0) { + return ''; + } + + // Nur Umschläge im free-Modus filtern + const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'free'); + if (envelopes.length === 0) { + return ''; + } + + // Freie Adressen haben bis zu 5 Zeilen + const keys = ['UmschlagNr', 'Zeile1', 'Zeile2', 'Zeile3', 'Zeile4', 'Zeile5']; + const header = keys.join(','); + + const rows = envelopes.map((envelope, index) => { + const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0'); + + // Text in Zeilen aufteilen + const textLines = (envelope.text || '').split('\n'); + const values = [umschlagNr]; + + for (let i = 0; i < 5; i++) { + values.push(escapeCSV(textLines[i] || '')); + } + + return values.join(','); + }); + + return [header, ...rows].join('\n'); +} + +/** + * Generiert CSV für Umschlag-Platzhalter (Umschläge im customText-Modus) + * @param {Array} letters - Array von Letter-Objekten + * @returns {string} - CSV-String + */ +function generateEnvelopePlaceholderCSV(letters) { + if (!Array.isArray(letters) || letters.length === 0) { + console.log('[CSV] generateEnvelopePlaceholderCSV: No letters'); + return ''; + } + + // Nur Umschläge im custom-Modus filtern + const envelopes = letters.filter(l => l.type === 'envelope' && l.envelopeType === 'custom'); + console.log('[CSV] Found custom envelopes:', envelopes.length); + console.log('[CSV] Envelope details:', envelopes.map(e => ({ type: e.type, envelopeType: e.envelopeType, placeholders: e.placeholders }))); + + if (envelopes.length === 0) { + return ''; + } + + // Sammle alle unique Keys (außer Standard-Empfängerfelder) + const recipientKeys = ['vorname', 'name', 'strasse', 'hausnummer', 'plz', 'ort', 'land']; + const allKeys = new Set(); + envelopes.forEach(envelope => { + if (envelope.placeholders) { + console.log('[CSV] Envelope placeholders keys:', Object.keys(envelope.placeholders)); + Object.keys(envelope.placeholders).forEach(key => { + // Nur nicht-Empfängerfelder sammeln + if (!recipientKeys.includes(key.toLowerCase())) { + allKeys.add(key); + console.log('[CSV] Added key:', key); + } else { + console.log('[CSV] Skipped recipient key:', key); + } + }); + } + }); + + console.log('[CSV] All collected keys:', Array.from(allKeys)); + + if (allKeys.size === 0) { + console.log('[CSV] No custom placeholder keys found for envelopes'); + return ''; + } + + const keys = ['UmschlagNr', ...Array.from(allKeys).sort()]; + const header = keys.join(','); + + const rows = envelopes.map((envelope, index) => { + const umschlagNr = String(envelope.index !== undefined ? envelope.index : index).padStart(3, '0'); + const values = [umschlagNr]; + + for (let i = 1; i < keys.length; i++) { + const key = keys[i]; + const value = envelope.placeholders?.[key] || ''; + values.push(escapeCSV(value)); + } + + return values.join(','); + }); + + return [header, ...rows].join('\n'); +} + +/** + * Generiert alle CSV-Dateien für eine Bestellung + * @param {Array} letters - Array von Letter-Objekten (Briefe und Umschläge) + * @returns {Object} - Objekt mit CSV-Inhalten { placeholders, recipients, envelopePlaceholders, freeAddresses } + */ +function generateAllCSVs(letters) { + const result = { + placeholders: null, // Brief-Platzhalter + recipients: null, // Empfängerdaten für Umschläge (recipientData-Modus) + envelopePlaceholders: null, // Umschlag-Platzhalter (customText-Modus) + freeAddresses: null // Freie Adressen für Umschläge (free-Modus) + }; + + if (!Array.isArray(letters) || letters.length === 0) { + return result; + } + + // Brief-Platzhalter CSV + const placeholderCSV = generatePlaceholderCSV(letters); + if (placeholderCSV) { + result.placeholders = placeholderCSV; + } + + // Empfänger CSV (für recipientData-Modus) + const recipientCSV = generateRecipientCSV(letters); + if (recipientCSV) { + result.recipients = recipientCSV; + } + + // Umschlag-Platzhalter CSV (für customText-Modus) + const envelopePlaceholderCSV = generateEnvelopePlaceholderCSV(letters); + if (envelopePlaceholderCSV) { + result.envelopePlaceholders = envelopePlaceholderCSV; + } + + // Freie Adressen CSV (für free-Modus) + const freeAddressCSV = generateFreeAddressCSV(letters); + if (freeAddressCSV) { + result.freeAddresses = freeAddressCSV; + } + + return result; +} + +module.exports = { + replacePlaceholders, + generatePlaceholderCSV, + generateRecipientCSV, + generateEnvelopePlaceholderCSV, + generateFreeAddressCSV, + generateAllCSVs, + escapeCSV +}; diff --git a/Docker Backend/src/services/scriptalizer-service.js b/Docker Backend/src/services/scriptalizer-service.js new file mode 100644 index 0000000..c20bbee --- /dev/null +++ b/Docker Backend/src/services/scriptalizer-service.js @@ -0,0 +1,230 @@ +const https = require("https"); +const { parseStringPromise } = require("xml2js"); +const config = require("../config"); + +// API Request Counter (resets daily) +const stats = { + requestsToday: 0, + textsToday: 0, + lastResetDate: new Date().toDateString(), +}; + +/** + * Prüft ob der Zähler zurückgesetzt werden muss (neuer Tag) + */ +function checkAndResetDailyStats() { + const today = new Date().toDateString(); + if (stats.lastResetDate !== today) { + console.log(`[Scriptalizer] 📅 New day detected, resetting counters`); + console.log(`[Scriptalizer] 📊 Yesterday's stats: ${stats.requestsToday} API calls, ${stats.textsToday} texts processed`); + stats.requestsToday = 0; + stats.textsToday = 0; + stats.lastResetDate = today; + } +} + +/** + * Gibt die aktuellen Stats zurück + */ +function getStats() { + checkAndResetDailyStats(); + return { ...stats }; +} + +/** + * Ruft die Scriptalizer API auf + * @param {string} inputText - Text zum Scriptalisieren + * @param {string} fontName - Font-Name (z.B. 'PremiumUltra79') + * @param {number} errFrequency - Fehlerfrequenz + * @returns {Promise<{status: string, outputText: string}>} + */ +async function callScriptalizer(inputText, fontName, errFrequency = 10) { + // Prüfe ob neuer Tag + checkAndResetDailyStats(); + + return new Promise((resolve, reject) => { + if (!config.scriptalizer.licenseKey) { + reject(new Error("Scriptalizer License Key fehlt in Konfiguration")); + return; + } + + // Use x-www-form-urlencoded format + const params = new URLSearchParams(); + params.append("LicenseKey", config.scriptalizer.licenseKey); + params.append("FontName", fontName); + params.append("ErrFrequency", String(errFrequency)); + params.append("InputText", inputText); + + const body = params.toString(); + + // Check input size (48KB limit) + if (Buffer.byteLength(body) > config.scriptalizer.maxInputSize) { + reject( + new Error( + `Input size exceeds 48KB limit (${Buffer.byteLength(body)} bytes)` + ) + ); + return; + } + + const options = { + hostname: "www.scriptalizer.co.uk", + port: 443, + path: "/QuantumScriptalize.asmx/Scriptalize", + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + }, + timeout: 30000, // 30 second timeout + }; + + const req = https.request(options, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", async () => { + try { + const result = await parseStringPromise(data, { + explicitArray: false, + }); + const response = result.ScriptalizerResponse; + + if (!response || response.Status !== "OK") { + reject(new Error(response?.Status || "UNKNOWN_STATUS")); + return; + } + + // Zähler erhöhen bei erfolgreichem Request + stats.requestsToday++; + console.log(`[Scriptalizer] 📊 API Request #${stats.requestsToday} today completed`); + + resolve({ status: "OK", outputText: response.OutputText }); + } catch (err) { + reject( + new Error(`Failed to parse Scriptalizer response: ${err.message}`) + ); + } + }); + }); + + req.on("error", (err) => { + reject(new Error(`Scriptalizer request failed: ${err.message}`)); + }); + + req.on("timeout", () => { + req.destroy(); + reject(new Error("Scriptalizer request timed out")); + }); + + req.write(body); + req.end(); + }); +} + +/** + * Scriptalisiert mehrere Texte in 25er Batches + * @param {string[]} texts - Array von Texten + * @param {string} font - Font-Key (tilda, alva, ellie) + * @param {number} errFrequency - Fehlerfrequenz + * @returns {Promise} - Array von scriptalisierten Texten + */ +async function scriptalizeBatch(texts, font = "tilda", errFrequency = 10) { + if (!Array.isArray(texts) || texts.length === 0) { + return []; + } + + const fontName = + config.scriptalizer.fontMap[font.toLowerCase()] || + config.scriptalizer.fontMap.tilda; + const separator = config.scriptalizer.separator; + const BATCH_SIZE = 25; // 25 Texte pro Scriptalizer API Call + + console.log( + `[Scriptalizer] Processing ${texts.length} texts in batches of ${BATCH_SIZE}` + ); + + const allScriptalized = []; + + // Teile in 25er Batches auf + for (let i = 0; i < texts.length; i += BATCH_SIZE) { + const batch = texts.slice(i, i + BATCH_SIZE); + const batchNumber = Math.floor(i / BATCH_SIZE) + 1; + const totalBatches = Math.ceil(texts.length / BATCH_SIZE); + + console.log( + `[Scriptalizer] Processing batch ${batchNumber}/${totalBatches} (${ + batch.length + } texts, indices ${i}-${i + batch.length - 1})` + ); + + // Kombiniere Texte mit Separator + const combinedText = batch.join(separator); + + try { + const result = await callScriptalizer( + combinedText, + fontName, + errFrequency + ); + + // Trenne Ergebnis wieder + const scriptalized = result.outputText.split(separator); + + if (scriptalized.length !== batch.length) { + console.warn( + `[Scriptalizer] Batch ${batchNumber}: returned ${scriptalized.length} parts, expected ${batch.length}` + ); + } + + allScriptalized.push(...scriptalized); + console.log( + `[Scriptalizer] Batch ${batchNumber}/${totalBatches} completed successfully` + ); + } catch (err) { + console.error(`[Scriptalizer] Batch ${batchNumber} failed:`, err.message); + throw err; + } + } + + // Textzähler erhöhen + stats.textsToday += allScriptalized.length; + + console.log( + `[Scriptalizer] All batches completed. Total scriptalized: ${allScriptalized.length}` + ); + console.log( + `[Scriptalizer] 📊 Daily stats: ${stats.requestsToday} API calls, ${stats.textsToday} texts processed` + ); + return allScriptalized; +} + +/** + * Scriptalisiert einen einzelnen Text + * @param {string} text - Text zum Scriptalisieren + * @param {string} font - Font-Key (tilda, alva, ellie) + * @param {number} errFrequency - Fehlerfrequenz + * @returns {Promise} - Scriptalisierter Text + */ +async function scriptalizeSingle(text, font = "tilda", errFrequency = 10) { + const fontName = + config.scriptalizer.fontMap[font.toLowerCase()] || + config.scriptalizer.fontMap.tilda; + + try { + const result = await callScriptalizer(text, fontName, errFrequency); + return result.outputText; + } catch (err) { + console.error("Scriptalizer single call failed:", err.message); + throw err; + } +} + +module.exports = { + scriptalizeBatch, + scriptalizeSingle, + getStats, +}; diff --git a/Docker Backend/test-9-orders.json b/Docker Backend/test-9-orders.json new file mode 100644 index 0000000..041a974 --- /dev/null +++ b/Docker Backend/test-9-orders.json @@ -0,0 +1,58 @@ +{ + "orders": [ + { + "orderNumber": "SK-2026-01-02-TILDA-A4", + "font": "tilda", + "format": "a4", + "envelopeType": "recipient" + }, + { + "orderNumber": "SK-2026-01-02-TILDA-A6Q", + "font": "tilda", + "format": "a6l", + "envelopeType": "recipient" + }, + { + "orderNumber": "SK-2026-01-02-TILDA-A6H", + "font": "tilda", + "format": "a6p", + "envelopeType": "custom" + }, + { + "orderNumber": "SK-2026-01-02-ALVA-A4", + "font": "alva", + "format": "a4", + "envelopeType": "recipient" + }, + { + "orderNumber": "SK-2026-01-02-ALVA-A6Q", + "font": "alva", + "format": "a6l", + "envelopeType": "recipient" + }, + { + "orderNumber": "SK-2026-01-02-ALVA-A6H", + "font": "alva", + "format": "a6p", + "envelopeType": "custom" + }, + { + "orderNumber": "SK-2026-01-02-ELLIE-A4", + "font": "ellie", + "format": "a4", + "envelopeType": "recipient" + }, + { + "orderNumber": "SK-2026-01-02-ELLIE-A6Q", + "font": "ellie", + "format": "a6l", + "envelopeType": "recipient" + }, + { + "orderNumber": "SK-2026-01-02-ELLIE-A6H", + "font": "ellie", + "format": "a6p", + "envelopeType": "custom" + } + ] +} diff --git a/Docker Backend/test-api.sh b/Docker Backend/test-api.sh new file mode 100644 index 0000000..85ed413 --- /dev/null +++ b/Docker Backend/test-api.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Test Skrift Backend API + +BASE_URL="http://localhost:4000" +SESSION_ID="test-$(date +%s)" + +echo "==========================================" +echo "Testing Skrift Backend API" +echo "==========================================" + +echo -e "\n1. Health Check..." +curl -s "$BASE_URL/health" | python -m json.tool + +echo -e "\n\n2. Preview Batch (Single Letter)..." +curl -s -X POST "$BASE_URL/api/preview/batch" \ + -H "Content-Type: application/json" \ + -d '{ + "sessionId": "'"$SESSION_ID"'", + "batchIndex": 0, + "config": { + "font": "tilda", + "letters": [ + { + "index": 0, + "format": "a4", + "text": "Hallo [[Vorname]] [[Nachname]],\n\ndein persönlicher Gutscheincode lautet: [[Gutscheincode]]\n\nViele Grüße", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Gutscheincode": "SAVE20" + } + } + ], + "envelopes": [] + } + }' | python -m json.tool + +echo -e "\n\nDone! Session ID: $SESSION_ID" diff --git a/Docker Backend/test-complete.json b/Docker Backend/test-complete.json new file mode 100644 index 0000000..c24b15b --- /dev/null +++ b/Docker Backend/test-complete.json @@ -0,0 +1,85 @@ +{ + "sessionId": "test-complete-workflow", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nhiermit bestätigen wir Ihre Bestellung mit der Nummer [[Bestellnummer]].\n\nIhr persönlicher Gutscheincode lautet: [[Gutscheincode]]\n\nEr ist gültig bis zum [[Ablaufdatum]].\n\nVielen Dank für Ihr Vertrauen!\n\nMit freundlichen Grüßen\nIhr Skrift-Team", + "placeholders": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Bestellnummer": "SK-2026-001", + "Gutscheincode": "SAVE20", + "Ablaufdatum": "31.12.2026", + "Strasse": "Hauptstr. 1", + "PLZ": "10115", + "Ort": "Berlin" + } + }, + { + "index": 1, + "format": "a6p", + "font": "alva", + "text": "Liebe/r [[Vorname]],\n\nvielen Dank für deine Bestellung!\n\nDein Code: [[Gutscheincode]]\n\nHerzliche Grüße", + "placeholders": { + "Vorname": "Anna", + "Nachname": "Schmidt", + "Gutscheincode": "WINTER50", + "Strasse": "Bahnhofstr. 5", + "PLZ": "80331", + "Ort": "München" + } + }, + { + "index": 2, + "format": "a6l", + "font": "ellie", + "text": "Alles Gute zum Geburtstag, [[Vorname]]!\n\nWir wünschen dir einen wundervollen Tag!", + "placeholders": { + "Vorname": "Julia", + "Nachname": "Weber", + "Strasse": "Lindenweg 12", + "PLZ": "50667", + "Ort": "Köln" + } + } + ], + "envelopes": [ + { + "index": 0, + "format": "c6", + "font": "tilda", + "type": "recipient", + "data": { + "Vorname": "Max", + "Nachname": "Mustermann", + "Strasse": "Hauptstr. 1", + "PLZ": "10115", + "Ort": "Berlin" + } + }, + { + "index": 1, + "format": "c6", + "font": "alva", + "type": "recipient", + "data": { + "Vorname": "Anna", + "Nachname": "Schmidt", + "Strasse": "Bahnhofstr. 5", + "PLZ": "80331", + "Ort": "München" + } + }, + { + "index": 2, + "format": "din_lang", + "font": "ellie", + "type": "custom", + "data": { + "customText": "Für meine liebe Freundin Julia" + } + } + ] +} diff --git a/Docker Backend/test-finalize.json b/Docker Backend/test-finalize.json new file mode 100644 index 0000000..43e9799 --- /dev/null +++ b/Docker Backend/test-finalize.json @@ -0,0 +1,4 @@ +{ + "sessionId": "test-complete-workflow", + "orderNumber": "SK-2026-01-02-001" +} diff --git a/Docker Backend/test-generate-with-envelopes.json b/Docker Backend/test-generate-with-envelopes.json new file mode 100644 index 0000000..9a2bf78 --- /dev/null +++ b/Docker Backend/test-generate-with-envelopes.json @@ -0,0 +1,36 @@ +{ + "orderNumber": "SK-2026-01-02-002", + "config": { + "font": "tilda", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!\n\nMit freundlichen Grüßen", + "placeholders": { + "Vorname": "Thomas", + "Nachname": "Müller", + "Strasse": "Lindenweg 12", + "PLZ": "50667", + "Ort": "Köln" + } + } + ], + "envelopes": [ + { + "index": 0, + "format": "c6", + "font": "tilda", + "type": "recipient", + "data": { + "Vorname": "Thomas", + "Nachname": "Müller", + "Strasse": "Lindenweg 12", + "PLZ": "50667", + "Ort": "Köln" + } + } + ] + } +} diff --git a/Docker Backend/test-order-direct.json b/Docker Backend/test-order-direct.json new file mode 100644 index 0000000..72b04e1 --- /dev/null +++ b/Docker Backend/test-order-direct.json @@ -0,0 +1,52 @@ +{ + "orderNumber": "SK-2026-01-02-003", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte/r [[Vorname]] [[Nachname]],\n\nvielen Dank für Ihre Bestellung!\n\nMit freundlichen Grüßen", + "placeholders": { + "Vorname": "Thomas", + "Nachname": "Müller", + "Strasse": "Lindenweg 12", + "PLZ": "50667", + "Ort": "Köln" + } + }, + { + "index": 1, + "format": "a6p", + "font": "alva", + "text": "Hallo [[Vorname]]!\n\nSchöne Grüße!", + "placeholders": { + "Vorname": "Sarah", + "Nachname": "Fischer" + } + } + ], + "envelopes": [ + { + "index": 0, + "format": "c6", + "font": "tilda", + "type": "recipient", + "data": { + "Vorname": "Thomas", + "Nachname": "Müller", + "Strasse": "Lindenweg 12", + "PLZ": "50667", + "Ort": "Köln" + } + }, + { + "index": 1, + "format": "din_lang", + "font": "alva", + "type": "custom", + "data": { + "customText": "Für Sarah - Alles Gute!" + } + } + ] +} diff --git a/Docker Backend/test-request.json b/Docker Backend/test-request.json new file mode 100644 index 0000000..3cf5ff5 --- /dev/null +++ b/Docker Backend/test-request.json @@ -0,0 +1,12 @@ +{ + "sessionId": "test-simple", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Hallo Max Mustermann,\n\ndein persönlicher Gutscheincode lautet: SAVE20\n\nViele Grüße", + "placeholders": {} + } + ] +} diff --git a/Docker Backend/test-scriptalizer-direct.js b/Docker Backend/test-scriptalizer-direct.js new file mode 100644 index 0000000..801ca53 --- /dev/null +++ b/Docker Backend/test-scriptalizer-direct.js @@ -0,0 +1,127 @@ +/** + * Zeigt den exakten Scriptalizer API-Call für Tilda Font + */ + +const https = require('https'); + +const LICENSE_KEY = 'f9918b40-d11c-11f0-b558-0800200c9a66'; +const FONT_NAME = 'PremiumUltra79'; // Tilda +const ERR_FREQUENCY = 0; +const INPUT_TEXT = 'Sehr geehrte Damen und Herren,\n\nhiermit bestätigen wir Ihre Bestellung.\n\nMit freundlichen Grüßen\nIhr Skrift-Team'; + +// Build request body (x-www-form-urlencoded) +const params = new URLSearchParams(); +params.append('LicenseKey', LICENSE_KEY); +params.append('FontName', FONT_NAME); +params.append('ErrFrequency', String(ERR_FREQUENCY)); +params.append('InputText', INPUT_TEXT); + +const body = params.toString(); + +console.log('╔════════════════════════════════════════════════════════════╗'); +console.log('║ SCRIPTALIZER API CALL - TILDA FONT ║'); +console.log('╚════════════════════════════════════════════════════════════╝\n'); + +console.log('📍 ENDPOINT:'); +console.log('https://www.scriptalizer.co.uk/QuantumScriptalize.asmx/Scriptalize\n'); + +console.log('📋 METHOD:'); +console.log('POST\n'); + +console.log('📦 HEADERS:'); +console.log({ + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Content-Length': Buffer.byteLength(body) +}); +console.log(''); + +console.log('📝 REQUEST BODY (x-www-form-urlencoded):'); +console.log('─'.repeat(60)); +console.log(body); +console.log('─'.repeat(60)); +console.log(''); + +console.log('📊 DECODED PARAMETERS:'); +console.log({ + LicenseKey: LICENSE_KEY, + FontName: FONT_NAME, + ErrFrequency: ERR_FREQUENCY, + InputText: INPUT_TEXT.substring(0, 100) + '...' +}); +console.log(''); + +console.log('🔧 CURL COMMAND:'); +console.log('─'.repeat(60)); +console.log(`curl -X POST 'https://www.scriptalizer.co.uk/QuantumScriptalize.asmx/Scriptalize' \\ + -H 'Content-Type: application/x-www-form-urlencoded; charset=utf-8' \\ + -d 'LicenseKey=${LICENSE_KEY}' \\ + -d 'FontName=${FONT_NAME}' \\ + -d 'ErrFrequency=${ERR_FREQUENCY}' \\ + -d 'InputText=${INPUT_TEXT.replace(/\n/g, '\\n')}'`); +console.log('─'.repeat(60)); +console.log(''); + +console.log('📡 SENDING REQUEST TO SCRIPTALIZER...\n'); + +const options = { + hostname: 'www.scriptalizer.co.uk', + port: 443, + path: '/QuantumScriptalize.asmx/Scriptalize', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Content-Length': Buffer.byteLength(body) + }, + timeout: 30000 +}; + +const req = https.request(options, (res) => { + let data = ''; + + console.log(`📥 RESPONSE STATUS: ${res.statusCode}`); + console.log(`📥 RESPONSE HEADERS:`); + console.log(res.headers); + console.log(''); + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + console.log('📄 RESPONSE BODY (XML):'); + console.log('─'.repeat(60)); + console.log(data); + console.log('─'.repeat(60)); + console.log(''); + + // Parse response + if (data.includes('OK')) { + console.log('✅ Status: OK'); + + // Extract OutputText + const outputMatch = data.match(/([\s\S]*?)<\/OutputText>/); + if (outputMatch) { + const outputText = outputMatch[1]; + console.log('\n📝 SCRIPTALIZED TEXT (first 200 chars):'); + console.log('─'.repeat(60)); + console.log(outputText.substring(0, 200) + '...'); + console.log('─'.repeat(60)); + console.log(`\n📏 OUTPUT LENGTH: ${outputText.length} characters`); + } + } else { + console.log('❌ Error in response'); + } + }); +}); + +req.on('error', (err) => { + console.error('❌ Request error:', err.message); +}); + +req.on('timeout', () => { + req.destroy(); + console.error('❌ Request timeout'); +}); + +req.write(body); +req.end(); diff --git a/Docker Backend/test-scriptalizer.js b/Docker Backend/test-scriptalizer.js new file mode 100644 index 0000000..2779cc4 --- /dev/null +++ b/Docker Backend/test-scriptalizer.js @@ -0,0 +1,198 @@ +/** + * Scriptalizer Separator Test + * Tests which separator character survives Scriptalizer API processing + */ + +const https = require('https'); +const { parseStringPromise } = require('xml2js'); + +const LICENSE_KEY = 'f9918b40-d11c-11f0-b558-0800200c9a66'; + +// Test different separators +const SEPARATORS = [ + { name: 'Triple Pipe', value: '|||' }, + { name: 'Triple Tilde', value: '~~~' }, + { name: 'Triple Hash', value: '###' }, + { name: 'Paragraph Signs', value: '§§§' }, + { name: 'Double Pipe', value: '||' }, + { name: 'Custom Marker', value: '___SKRIFT___' }, + { name: 'Newline + Dashes', value: '\n---\n' }, +]; + +async function callScriptalizer(inputText) { + return new Promise((resolve, reject) => { + // Use x-www-form-urlencoded format + const params = new URLSearchParams(); + params.append('LicenseKey', LICENSE_KEY); + params.append('FontName', 'PremiumUltra79'); + params.append('ErrFrequency', '10'); + params.append('InputText', inputText); + + const body = params.toString(); + + const options = { + hostname: 'www.scriptalizer.co.uk', + port: 443, + path: '/QuantumScriptalize.asmx/Scriptalize', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + 'Content-Length': Buffer.byteLength(body) + } + }; + + const req = https.request(options, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', async () => { + try { + const result = await parseStringPromise(data, { explicitArray: false }); + const response = result.ScriptalizerResponse; + + if (!response || response.Status !== 'OK') { + reject(new Error(response?.Status || 'UNKNOWN_STATUS')); + return; + } + + resolve({ status: 'OK', outputText: response.OutputText }); + } catch (err) { + reject(err); + } + }); + }); + + req.on('error', (err) => { + reject(err); + }); + + req.write(body); + req.end(); + }); +} + +async function testSeparator(separator) { + console.log(`\n${'='.repeat(60)}`); + console.log(`Testing: ${separator.name} (${JSON.stringify(separator.value)})`); + console.log('='.repeat(60)); + + // Create test input with 3 short texts separated by the separator + const texts = [ + 'Hallo Max Mustermann aus Berlin', + 'Liebe Anna Schmidt aus München', + 'Sehr geehrter Tom Weber aus Hamburg' + ]; + + const inputText = texts.join(separator.value); + + console.log('\nInput:'); + console.log(inputText.substring(0, 150)); + console.log(`\nSeparator appears: ${(inputText.match(new RegExp(separator.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length} times`); + + try { + const result = await callScriptalizer(inputText); + + console.log('\nOutput (first 200 chars):'); + console.log(result.outputText.substring(0, 200) + '...'); + + // Check if separator survived + const separatorRegex = new RegExp(separator.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); + const separatorCount = (result.outputText.match(separatorRegex) || []).length; + + console.log(`\nSeparator found in output: ${separatorCount} times`); + + if (separatorCount === 2) { + console.log('✅ SUCCESS: Separator survived!'); + + // Try to split + const split = result.outputText.split(separator.value); + console.log(`\nSplit result: ${split.length} parts`); + + if (split.length === 3) { + console.log('✅ PERFECT: Can split into 3 parts!'); + console.log('\nPart 1 (first 50 chars):', split[0].substring(0, 50) + '...'); + console.log('Part 2 (first 50 chars):', split[1].substring(0, 50) + '...'); + console.log('Part 3 (first 50 chars):', split[2].substring(0, 50) + '...'); + + return { separator: separator.name, value: separator.value, success: true, split: true }; + } else { + console.log('⚠️ WARNING: Split count mismatch'); + return { separator: separator.name, value: separator.value, success: true, split: false }; + } + } else { + console.log('❌ FAILED: Separator was modified or not found'); + return { separator: separator.name, value: separator.value, success: false, found: separatorCount }; + } + + } catch (err) { + console.log(`\n❌ ERROR: ${err.message}`); + return { separator: separator.name, value: separator.value, success: false, error: err.message }; + } +} + +async function runTests() { + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ Scriptalizer Separator Test Suite ║'); + console.log('║ Testing which separator survives API processing ║'); + console.log('╚════════════════════════════════════════════════════════════╝'); + + const results = []; + + for (const separator of SEPARATORS) { + const result = await testSeparator(separator); + results.push(result); + + // Wait 2 seconds between tests to avoid rate limiting + if (separator !== SEPARATORS[SEPARATORS.length - 1]) { + console.log('\nWaiting 2 seconds before next test...'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } + + // Summary + console.log('\n\n' + '═'.repeat(60)); + console.log('SUMMARY'); + console.log('═'.repeat(60)); + + const successful = results.filter(r => r.success && r.split); + const partial = results.filter(r => r.success && !r.split); + const failed = results.filter(r => !r.success); + + console.log('\n✅ Fully Working Separators (can split):'); + if (successful.length === 0) { + console.log(' None'); + } else { + successful.forEach(r => console.log(` - ${r.separator} (${JSON.stringify(r.value)})`)); + } + + console.log('\n⚠️ Partially Working Separators (found but cannot split):'); + if (partial.length === 0) { + console.log(' None'); + } else { + partial.forEach(r => console.log(` - ${r.separator}`)); + } + + console.log('\n❌ Failed Separators:'); + if (failed.length === 0) { + console.log(' None'); + } else { + failed.forEach(r => console.log(` - ${r.separator} (${r.error || 'modified by API'})`)); + } + + console.log('\n' + '═'.repeat(60)); + + if (successful.length > 0) { + console.log(`\n🎉 RECOMMENDATION: Use "${successful[0].separator}" as separator`); + console.log(` Separator value: ${JSON.stringify(successful[0].value)}`); + } else if (partial.length > 0) { + console.log(`\n⚠️ RECOMMENDATION: Use "${partial[0].separator}" but verify split logic`); + } else { + console.log('\n❌ No separator worked - need to use individual API calls'); + } +} + +// Run tests +runTests().catch(console.error); diff --git a/Docker Backend/test-tilda-preview.json b/Docker Backend/test-tilda-preview.json new file mode 100644 index 0000000..1018dd5 --- /dev/null +++ b/Docker Backend/test-tilda-preview.json @@ -0,0 +1,12 @@ +{ + "sessionId": "tilda-test-demo", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Sehr geehrte Damen und Herren,\n\nhiermit möchten wir Ihnen mitteilen, dass Ihre Bestellung erfolgreich bearbeitet wurde.\n\nWir bedanken uns für Ihr Vertrauen und freuen uns auf eine weitere Zusammenarbeit.\n\nMit freundlichen Grüßen\nIhr Skrift-Team", + "placeholders": {} + } + ] +} diff --git a/Docker Backend/test-variation.json b/Docker Backend/test-variation.json new file mode 100644 index 0000000..26b28bd --- /dev/null +++ b/Docker Backend/test-variation.json @@ -0,0 +1,12 @@ +{ + "sessionId": "variation-test", + "letters": [ + { + "index": 0, + "format": "a4", + "font": "tilda", + "text": "Dies ist ein Test mit natürlicher Handschrift-Variation.\n\nDie Wörter haben unterschiedliche Abstände und eine leichte Schräglage.\n\nDas macht das Schriftbild authentischer und lebendiger.\n\nVielen Dank für Ihr Vertrauen!", + "placeholders": {} + } + ] +} diff --git a/FRONTEND_BACKEND_ZUSAMMENFASSUNG.md b/FRONTEND_BACKEND_ZUSAMMENFASSUNG.md new file mode 100644 index 0000000..0cc1439 --- /dev/null +++ b/FRONTEND_BACKEND_ZUSAMMENFASSUNG.md @@ -0,0 +1,476 @@ +# Frontend-Backend Integration - Zusammenfassung + +Komplette Übersicht über die Integration des WordPress Frontends mit dem Node.js Backend. + +## Änderungen in diesem Chat + +### ❌ Keine Frontend-Änderungen besprochen + +In diesem Chat lag der Fokus komplett auf dem **Backend**: +- Backend-Entwicklung (Node.js/Express) +- Docker-Deployment +- Scriptalizer API Integration +- SVG-Generierung mit Variationen + +Das Frontend (WordPress Plugin) wurde **jetzt** für das Backend angepasst. + +## Neue Dateien im WordPress Plugin + +### 1. `assets/js/configurator-api.js` +**Backend API Client** + +Funktionen: +- `healthCheck()` - Prüft ob Backend erreichbar ist +- `generatePreviewBatch(letters)` - Generiert Preview von Briefen +- `getPreviewUrl(sessionId, index)` - Holt Preview-URL +- `finalizeOrder(sessionId, orderNumber, metadata)` - Finalisiert Order aus Preview +- `generateOrder(orderNumber, letters, envelopes, metadata)` - Erstellt Order direkt +- `generateOrderNumber()` - Generiert Bestellnummer (SK-YYYY-MM-DD-XXX) + +### 2. `assets/js/configurator-backend-integration.js` +**Integration Logic** + +Funktionen: +- `handleOrderSubmitWithBackend(state)` - Erweiterte Order-Submit mit Backend +- `prepareLettersForBackend(state)` - Bereitet Letter-Daten vor +- `prepareEnvelopesForBackend(state)` - Bereitet Envelope-Daten vor +- `mapFontToBackend(font)` - Mappt Frontend-Font zu Backend +- `mapFormatToBackend(format)` - Mappt Frontend-Format zu Backend + +### 3. `BACKEND_INTEGRATION.md` +**Vollständige Integrations-Dokumentation** + +Inhalt: +- WordPress Admin-Einstellungen Anleitung +- Backend-API Endpunkte Dokumentation +- Workflow-Beschreibungen +- Datenmapping-Tabellen +- Troubleshooting Guide +- Testing-Anleitungen + +### 4. `README.md` +**Plugin-Dokumentation** + +Inhalt: +- Features-Übersicht +- Installations-Anleitung +- Konfigurations-Guide +- Datei-Struktur +- API Integration +- Workflow-Diagramme +- Troubleshooting +- Changelog + +## WordPress Admin-Einstellungen + +### Bereits vorhanden (keine Änderung nötig!) + +Die Backend-Verbindungseinstellungen waren bereits im Plugin vorbereitet: + +**Einstellungen → Skrift Konfigurator → Backend-Verbindung:** + +1. **API URL / Domain** + - Beispiel: `https://backend.deine-domain.de` + - Pflichtfeld für Backend-Integration + +2. **API Token / Authentifizierung** + - Optional (aktuell nicht genutzt) + - Für zukünftige Erweiterungen + +3. **Order Webhook URL** + - Beispiel: `https://n8n.deine-domain.de/webhook/order` + - Wird nach Bestellung aufgerufen + +4. **Redirect URL Geschäftskunden** + - Beispiel: `https://deine-domain.de/danke-business` + - Wohin nach Business-Bestellung + +5. **Redirect URL Privatkunden** + - Beispiel: `https://deine-domain.de/danke-privat` + - Wohin nach Privat-Bestellung + +## Integration in bestehendes Frontend + +### Wie funktioniert es? + +Das neue Backend-System wird **automatisch** genutzt, wenn: +1. Backend-URL in WordPress-Einstellungen gesetzt ist +2. Backend erreichbar ist (Health-Check erfolgreich) + +### Fallback-Logik + +Falls Backend nicht erreichbar: +- Plugin fällt zurück auf alte Webhook-Only Logik +- Bestellung wird trotzdem durchgeführt +- Aber: Keine SVG-Generierung + +### Was ändert sich für den User? + +**Nichts!** Der Konfigurator funktioniert genau gleich: +1. Produkt auswählen +2. Menge eingeben +3. Format wählen +4. Versand & Umschlag +5. Inhalt eingeben +6. Kundendaten +7. Bestellen → **Jetzt mit Backend-Generierung!** + +## Datenmapping + +### Fonts + +| WordPress | Backend | Scriptalizer | +|-----------|---------|--------------| +| tilda | tilda | PremiumUltra79 | +| alva | alva | PremiumUltra23 | +| ellie | ellie | PremiumUltra39 | + +### Formate + +| WordPress | Backend | Papier | +|-----------|---------|--------| +| a4 | A4 | 210×297mm | +| a6p | A6_PORTRAIT | 105×148mm | +| a6l | A6_LANDSCAPE | 148×105mm | + +### Envelopes + +| Brief-Format | Envelope-Format | Größe | +|--------------|-----------------|-------| +| A4 | DIN_LANG | 110×220mm | +| A6 | C6 | 114×162mm | + +## Workflow-Übersicht + +### Business-Kunde (B2B) + +``` +┌─────────────────────────────────────────────────────────┐ +│ 1. Kunde füllt Konfigurator aus │ +│ - Businessbriefe / Business Postkarten / Follow-ups │ +│ - Menge, Format, Versand, Umschlag │ +│ - Text-Inhalt │ +│ - Kundendaten │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 2. Klick auf "Jetzt kostenpflichtig bestellen" │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 3. WordPress Plugin │ +│ - generateOrderNumber() → SK-2026-01-03-001 │ +│ - prepareLettersForBackend(state) │ +│ - prepareEnvelopesForBackend(state) │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 4. Backend API Call │ +│ POST /api/order/generate │ +│ { │ +│ orderNumber: "SK-2026-01-03-001", │ +│ letters: [...], │ +│ envelopes: [...], │ +│ metadata: {...} │ +│ } │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 5. Node.js Backend │ +│ - Scriptalizer API Call (Batch-Processing) │ +│ - SVG-Generierung mit Variationen │ +│ - Dateien speichern in /var/skrift-output/ │ +│ - Placeholders.csv erstellen │ +│ - order-metadata.json erstellen │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 6. Webhook aufrufen (optional) │ +│ POST https://n8n.deine-domain.de/webhook/order │ +│ { │ +│ orderNumber: "SK-2026-01-03-001", │ +│ customer_data: {...}, │ +│ backend_result: { │ +│ path: "/var/skrift-output/SK-2026-01-03-001", │ +│ files: [...] │ +│ } │ +│ } │ +└─────────────────────────┬───────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ 7. Weiterleitung │ +│ → https://domain.de/danke-business?orderNumber=SK-.. │ +└─────────────────────────────────────────────────────────┘ +``` + +### Privat-Kunde (B2C) - Später mit PayPal + +``` +1. Kunde füllt Konfigurator aus +2. Klick auf "Jetzt kostenpflichtig bestellen" +3. → PayPal Checkout +4. PayPal Zahlung erfolgreich +5. PayPal Webhook → WordPress +6. WordPress → Backend API (generateOrder) +7. Backend generiert SVG-Dateien +8. Webhook aufrufen +9. Weiterleitung zu Danke-Seite +``` + +## Generierte Dateien + +Für jede Bestellung erstellt das Backend: + +``` +/var/skrift-output/SK-2026-01-03-001/ +├── letter_000.svg # Brief 1 +├── letter_001.svg # Brief 2 +├── ... +├── letter_099.svg # Brief 100 +├── envelope_000.svg # Umschlag 1 (falls gewünscht) +├── envelope_001.svg # Umschlag 2 +├── ... +├── envelope_099.svg # Umschlag 100 +├── placeholders.csv # Platzhalter-Daten (CSV) +└── order-metadata.json # Bestellungs-Metadaten +``` + +### order-metadata.json + +```json +{ + "orderNumber": "SK-2026-01-03-001", + "generatedAt": "2026-01-03T12:34:56.789Z", + "summary": { + "totalLetters": 100, + "totalEnvelopes": 100, + "fonts": ["tilda"], + "formats": ["A4"] + }, + "metadata": { + "customer": { + "type": "business", + "firstName": "Max", + "lastName": "Mustermann", + "company": "Beispiel GmbH", + "email": "max@example.com" + }, + "product": "businessbriefe", + "quantity": 100 + } +} +``` + +### placeholders.csv + +```csv +PlaceholderName,Value +Anrede,Sehr geehrte Damen und Herren +Firma,Beispiel GmbH +Ansprechpartner,Max Mustermann +``` + +## Wichtige Konfigurationen + +### WordPress Plugin + +**Datei:** `WordPress Plugin/includes/admin-settings.php` + +Bereits vorhanden: +- Backend-Verbindungseinstellungen +- Preiskonfiguration +- Produktverwaltung +- Gutschein-System + +**Keine Änderungen nötig!** + +### Node.js Backend + +**Datei:** `Docker Backend/.env` + +```bash +SCRIPTALIZER_LICENSE_KEY=f9918b40-d11c-11f0-b558-0800200c9a66 +SCRIPTALIZER_ERR_FREQUENCY=0 # WICHTIG: Keine durchgestrichenen Wörter! +BATCH_SIZE=30 +CACHE_LIFETIME_HOURS=2 +RATE_LIMIT_PER_MINUTE=2 +NODE_ENV=production +``` + +**Datei:** `Docker Backend/src/lib/svg-font-engine.js` + +Handschrift-Variationen: +- **15% Wortabstand-Variation** (mit Sinuswelle) +- **±2.5° Wort-Rotation** (für natürliche Schräglage) + +## Testing + +### 1. Backend Health-Check + +```bash +# Auf Server +curl http://localhost:4000/health + +# Von außen +curl https://backend.deine-domain.de/health + +# Erwartete Antwort: +{"status":"ok","timestamp":"2026-01-03T..."} +``` + +### 2. WordPress Browser-Console + +```javascript +// Backend API testen +const api = window.SkriftBackendAPI; + +// Health-Check +const healthy = await api.healthCheck(); +console.log('Backend healthy:', healthy); + +// Test-Order +const result = await api.generateOrder( + api.generateOrderNumber(), + [{ + text: 'Liebe Oma, vielen Dank für das schöne Geschenk!', + font: 'tilda', + format: 'A4', + placeholders: {} + }], + [{ + type: 'recipient', + recipientAddress: { + name: 'Frau Schmidt', + street: 'Hauptstraße 123', + zip: '12345', + city: 'Berlin' + }, + font: 'tilda', + format: 'DIN_LANG' + }], + { + customer: { + type: 'business', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com' + } + } +); + +console.log('Order result:', result); +``` + +### 3. Vollständiger Test-Workflow + +1. WordPress Konfigurator öffnen +2. Produkt wählen (z.B. Businessbriefe) +3. Menge: 5 +4. Format: A4 +5. Versand: Direktversand +6. Umschlag: Ja, mit Empfängeradresse +7. Text eingeben +8. Kundendaten eingeben +9. "Jetzt kostenpflichtig bestellen" klicken +10. Prüfen: + - Browser-Console: Keine Fehler + - Backend-Logs: `docker compose logs -f` + - Dateien erstellt: `ls /var/skrift-output/SK-*` + - SVGs korrekt: Keine durchgestrichenen Wörter + - Webhook aufgerufen (falls konfiguriert) + +## Deployment-Reihenfolge + +### 1. Backend deployen + +```bash +# Siehe: Docker Backend/DEPLOYMENT_READY/START_HIER.txt + +# Upload auf Server +scp -r DEPLOYMENT_READY/* root@SERVER:/opt/skrift-backend/ + +# Auf Server +ssh root@SERVER +cd /opt/skrift-backend +cp .env.example .env +mkdir -p /var/skrift-output +docker compose up -d --build +``` + +### 2. Nginx Proxy Manager konfigurieren + +- Domain: `backend.deine-domain.de` +- Forward to: `skrift-backend:4000` +- SSL: Let's Encrypt + +### 3. WordPress Plugin hochladen + +```bash +# Per FTP/SSH +scp -r "WordPress Plugin" root@SERVER:/var/www/html/wp-content/plugins/skrift-konfigurator/ +``` + +### 4. WordPress Plugin aktivieren + +- WordPress Admin → Plugins +- "Skrift Konfigurator" aktivieren + +### 5. Einstellungen konfigurieren + +- Einstellungen → Skrift Konfigurator +- Backend-Verbindung: + - API URL: `https://backend.deine-domain.de` + - Webhook URL: `https://n8n.deine-domain.de/webhook/order` (optional) + - Redirect URLs setzen +- Speichern + +### 6. Seite mit Konfigurator erstellen + +- Neue Seite: "Konfigurator" +- Shortcode: `[skrift_konfigurator]` +- Veröffentlichen + +### 7. Testen! + +## Troubleshooting + +Siehe: +- `WordPress Plugin/BACKEND_INTEGRATION.md` +- `Docker Backend/DEPLOYMENT_READY/SERVER_SETUP.txt` +- `Docker Backend/DEPLOYMENT_READY/CHECKLISTE.txt` + +## Zusammenfassung + +### ✅ Was ist fertig? + +- ✅ Backend komplett entwickelt (Node.js + Docker) +- ✅ Frontend-Backend Integration implementiert +- ✅ API Client für WordPress +- ✅ Dokumentation vollständig +- ✅ Deployment-Package bereit +- ✅ Handschrift-Variationen (15% Abstand, ±2.5° Rotation) +- ✅ Keine durchgestrichenen Wörter (SCRIPTALIZER_ERR_FREQUENCY=0) + +### 🚧 Was fehlt noch? + +- [ ] Backend auf Server deployen +- [ ] WordPress Einstellungen konfigurieren +- [ ] Vollständigen Test-Durchlauf +- [ ] PayPal-Integration für Privatkunden +- [ ] Email-Benachrichtigungen +- [ ] N8N Workflow für Plotter-Übertragung + +### 🎯 Nächste Schritte + +1. **Backend deployen** (siehe `DEPLOYMENT_READY/START_HIER.txt`) +2. **WordPress-Einstellungen** konfigurieren +3. **Test-Bestellung** aufgeben +4. **Prüfen** ob SVG-Dateien korrekt generiert werden +5. **N8N Workflow** einrichten (optional) +6. **Go Live!** 🚀 diff --git a/n8n-email-template.html b/n8n-email-template.html new file mode 100644 index 0000000..047a587 --- /dev/null +++ b/n8n-email-template.html @@ -0,0 +1,268 @@ + + + + + + Bestellbestätigung + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $json.body.envelope ? ` + + + + ` : '' }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $json.body.customer_data.shippingDifferent ? ` + + + + + + + ` : '' }} + + + + + + + + + + + + + + + + + + + + + +
+

Bestellbestätigung

+
+

+ Guten Tag {{ $json.body.customer_data.billing.firstName }} {{ $json.body.customer_data.billing.lastName }}, +

+

+ vielen Dank für Ihre Bestellung bei Skrift. Wir haben Ihre Bestellung erhalten und werden diese schnellstmöglich bearbeiten. +

+
+ + + + +
+

Ihre Bestellnummer:

+

{{ $json.body.order_number }}

+
+
+
+
+

Ihre Bestellung

+
+ + + + + + + + + + + + + + + + + +
Produkt:{{ $json.body.product === 'businessbriefe' ? 'Business Briefe' : $json.body.product === 'business-postkarten' ? 'Business Postkarten' : $json.body.product === 'follow-ups' ? 'Follow-Ups' : $json.body.product === 'einladungen' ? 'Einladungen' : $json.body.product === 'private-briefe' ? 'Private Briefe' : $json.body.product }}
Menge:{{ $json.body.quantity }} Stück
Format:{{ $json.body.format === 'a4' ? 'A4 Hochformat' : $json.body.format === 'a6p' ? 'A6 Hochformat' : $json.body.format === 'a6l' ? 'A6 Querformat' : $json.body.format }}
Versand:{{ $json.body.shipping_mode === 'direct' ? 'Einzelversand durch Skrift' : 'Sammellieferung' }}
+
+ + + + +
+

Umschlag

+ + + + + + + + + +
Format:${ $json.body.format === 'a4' ? 'DIN Lang' : 'C6' }
Beschriftung:${ $json.body.envelope_mode === 'recipientData' ? 'Empfängeradresse' : $json.body.envelope_mode === 'customText' ? 'Individueller Text' : 'Keine' }
+
+
+
+
+

Preisübersicht

+
+ + + + + + + + + + + + + {{ $json.body.quote.voucher ? ` + + + + + ` : '' }} + + + + + + + + + +
Zwischensumme (netto):{{ $json.body.quote.subtotalNet.toFixed(2).replace('.', ',') }} {{ $json.body.quote.currency }}
MwSt. ({{ Math.round($json.body.quote.vatRate * 100) }}%):{{ $json.body.quote.vatAmount.toFixed(2).replace('.', ',') }} {{ $json.body.quote.currency }}
Gutschein (${ $json.body.quote.voucher.code }):-${ $json.body.quote.discountAmount.toFixed(2).replace('.', ',') } ${ $json.body.quote.currency }
+
+
Gesamtbetrag (brutto):{{ $json.body.quote.totalGross.toFixed(2).replace('.', ',') }} {{ $json.body.quote.currency }}
+
+
+
+

Rechnungsadresse

+
+

+ {{ $json.body.customer_data.billing.company ? `${$json.body.customer_data.billing.company}
` : '' }} + {{ $json.body.customer_data.billing.firstName }} {{ $json.body.customer_data.billing.lastName }}
+ {{ $json.body.customer_data.billing.street }}{{ $json.body.customer_data.billing.houseNumber ? ' ' + $json.body.customer_data.billing.houseNumber : '' }}
+ {{ $json.body.customer_data.billing.zip }} {{ $json.body.customer_data.billing.city }}
+ {{ $json.body.customer_data.billing.country }}
+
+ E-Mail: {{ $json.body.customer_data.billing.email }}
+ Telefon: {{ $json.body.customer_data.billing.phone }} +

+
+

Lieferadresse

+
+

+ ${ $json.body.customer_data.shipping.company ? '' + $json.body.customer_data.shipping.company + '
' : '' } + ${ $json.body.customer_data.shipping.firstName } ${ $json.body.customer_data.shipping.lastName }
+ ${ $json.body.customer_data.shipping.street }${ $json.body.customer_data.shipping.houseNumber ? ' ' + $json.body.customer_data.shipping.houseNumber : '' }
+ ${ $json.body.customer_data.shipping.zip } ${ $json.body.customer_data.shipping.city }
+ ${ $json.body.customer_data.shipping.country } +

+
+
+
+

Wie geht es weiter?

+
    +
  1. Wir prüfen Ihre Bestellung und bereiten die Produktion vor.
  2. +
  3. Nach Fertigstellung erhalten Sie eine Versandbestätigung.
  4. +
+
+ + + + +
+

Fragen zu Ihrer Bestellung?

+

+ Kontaktieren Sie uns unter
+ hello@skrift.de +

+
+
+

+ Mit freundlichen Grüßen
+ Ihr Skrift-Team +

+

+ Skrift | Hundscheiderweg 4 | 66679 Losheim am See
+ www.skrift.de +

+
+ +
+ + + diff --git a/skrift-configurator/.vscode/sftp.json b/skrift-configurator/.vscode/sftp.json new file mode 100644 index 0000000..1c8fafe --- /dev/null +++ b/skrift-configurator/.vscode/sftp.json @@ -0,0 +1,11 @@ +{ + "name": "skrift", + "host": "ae975.netcup.net", + "protocol": "ftp", + "port": 21, + "username": "skrift", + "remotePath": "/", + "uploadOnSave": true, + "useTempFile": false, + "openSsh": false +} diff --git a/skrift-configurator/BACKEND_INTEGRATION.md b/skrift-configurator/BACKEND_INTEGRATION.md new file mode 100644 index 0000000..f03863e --- /dev/null +++ b/skrift-configurator/BACKEND_INTEGRATION.md @@ -0,0 +1,388 @@ +# Backend Integration - WordPress Plugin + +Anleitung zur Integration des Node.js Backends mit dem WordPress Konfigurator-Plugin. + +## Überblick + +Das WordPress Plugin kommuniziert mit dem Node.js Backend über eine REST API. Das Backend generiert die handgeschriebenen SVG-Dateien und speichert die finalen Bestellungen. + +## WordPress Admin-Einstellungen + +Nach der Installation des Plugins in WordPress: + +1. Gehe zu **Einstellungen → Skrift Konfigurator** +2. Scrolle nach unten zum Abschnitt **"Backend-Verbindung"** + +### Erforderliche Einstellungen + +#### 1. API URL / Domain +**Beispiel:** `https://backend.deine-domain.de` + +Die vollständige URL zu deinem Backend-Server (ohne trailing slash). + +- ✅ `https://backend.example.com` +- ✅ `http://localhost:4000` (nur für lokale Tests) +- ❌ `https://backend.example.com/` (kein Slash am Ende!) + +#### 2. API Token / Authentifizierung +**Optional** - Aktuell nicht implementiert, für zukünftige Erweiterungen vorbereitet. + +Lasse dieses Feld erstmal leer. + +#### 3. Order Webhook URL +**Beispiel:** `https://n8n.deine-domain.de/webhook/order` + +URL die aufgerufen wird, nachdem eine Bestellung abgeschickt wurde. + +**Wird aufgerufen:** +- **Business-Kunden:** Sofort nach Klick auf "Jetzt kostenpflichtig bestellen" +- **Privat-Kunden:** Nach erfolgreicher PayPal-Zahlung (später implementiert) + +**Webhook-Payload:** +```json +{ + "orderNumber": "SK-2026-01-03-001", + "customer_type": "business", + "product": "businessbriefe", + "quantity": 100, + "format": "A4", + "shipping_mode": "direct", + "envelope": "yes", + "customer_data": { + "firstName": "Max", + "lastName": "Mustermann", + "company": "Beispiel GmbH", + "email": "max@example.com", + ... + }, + "quote": { + "total": 250.00, + ... + }, + "backend_result": { + "path": "/var/skrift-output/SK-2026-01-03-001", + "files": [...], + "summary": {...} + }, + "timestamp": "2026-01-03T..." +} +``` + +**Verwendung:** +- N8N Workflow triggern +- CRM-System benachrichtigen +- Interne Benachrichtigungen versenden + +#### 4. Redirect URL Geschäftskunden +**Beispiel:** `https://deine-domain.de/danke-business` + +Wohin Business-Kunden nach dem Klick auf "Jetzt kostenpflichtig bestellen" weitergeleitet werden. + +**Query-Parameter:** +- `orderNumber` - Die generierte Bestellnummer + +**Beispiel-Redirect:** +``` +https://deine-domain.de/danke-business?orderNumber=SK-2026-01-03-001 +``` + +#### 5. Redirect URL Privatkunden +**Beispiel:** `https://deine-domain.de/danke-privat` + +Wohin Privat-Kunden nach erfolgreicher PayPal-Zahlung weitergeleitet werden. + +**Query-Parameter:** +- `orderNumber` - Die generierte Bestellnummer + +## Backend-API Endpunkte + +Das Plugin nutzt folgende Backend-Endpunkte: + +### 1. Health Check +``` +GET /health +``` + +**Response:** +```json +{ + "status": "ok", + "timestamp": "2026-01-03T..." +} +``` + +### 2. Preview Batch +``` +POST /api/preview/batch +``` + +**Request:** +```json +{ + "sessionId": "session-1234567890-abc", + "letters": [ + { + "text": "Liebe Oma, ...", + "font": "tilda", + "format": "A4", + "placeholders": {}, + "envelope": null + } + ] +} +``` + +**Response:** +```json +{ + "sessionId": "session-1234567890-abc", + "previews": [ + { + "index": 0, + "url": "/api/preview/session-1234567890-abc/0" + } + ], + "batchInfo": { + "totalLetters": 1, + "batchSize": 30 + } +} +``` + +### 3. Generate Order +``` +POST /api/order/generate +``` + +**Request:** +```json +{ + "orderNumber": "SK-2026-01-03-001", + "letters": [...], + "envelopes": [...], + "metadata": { + "customer": {...}, + "orderDate": "2026-01-03T..." + } +} +``` + +**Response:** +```json +{ + "orderNumber": "SK-2026-01-03-001", + "path": "/var/skrift-output/SK-2026-01-03-001", + "files": [ + "letter_000.svg", + "envelope_000.svg", + "order-metadata.json", + "placeholders.csv" + ], + "summary": { + "totalLetters": 100, + "totalEnvelopes": 100, + "fonts": ["tilda"], + "formats": ["A4"] + } +} +``` + +## Workflow + +### Für Business-Kunden + +``` +1. Kunde füllt Konfigurator aus +2. Kunde klickt "Jetzt kostenpflichtig bestellen" +3. WordPress Plugin → Backend API (generateOrder) +4. Backend generiert SVG-Dateien +5. Backend speichert in /var/skrift-output/SK-... +6. WordPress Plugin → Webhook aufrufen +7. WordPress Plugin → Redirect zu Business-Danke-Seite +``` + +### Für Privat-Kunden (später) + +``` +1. Kunde füllt Konfigurator aus +2. Kunde klickt "Jetzt kostenpflichtig bestellen" +3. WordPress Plugin → PayPal Checkout +4. PayPal → Zahlung erfolgreich +5. PayPal Webhook → WordPress +6. WordPress Plugin → Backend API (generateOrder) +7. Backend generiert SVG-Dateien +8. WordPress Plugin → Webhook aufrufen +9. WordPress Plugin → Redirect zu Privat-Danke-Seite +``` + +## Datenmapping + +### Fonts + +| Frontend | Backend | +|----------|---------| +| tilda | tilda (PremiumUltra79) | +| alva | alva (PremiumUltra23) | +| ellie | ellie (PremiumUltra39) | + +### Formate + +| Frontend | Backend | +|----------|---------| +| a4 | A4 | +| a6p | A6_PORTRAIT | +| a6l | A6_LANDSCAPE | + +### Envelope-Formate + +| Brief-Format | Envelope-Format | +|--------------|-----------------| +| A4 | DIN_LANG | +| A6 | C6 | + +## JavaScript Integration + +### API Client laden + +Das Plugin lädt automatisch: +- `configurator-api.js` - Backend API Client +- `configurator-backend-integration.js` - Integration Logic + +### Globale Instanz + +```javascript +// Verfügbar in allen Konfigurator-Scripts +const api = window.SkriftBackendAPI; + +// Health-Check +const isHealthy = await api.healthCheck(); + +// Preview generieren +const result = await api.generatePreviewBatch(letters); + +// Order generieren +const order = await api.generateOrder(orderNumber, letters, envelopes, metadata); +``` + +## Testing + +### 1. Backend Health-Check + +```javascript +// In Browser-Console auf Konfigurator-Seite: +const api = window.SkriftBackendAPI; +const healthy = await api.healthCheck(); +console.log('Backend healthy:', healthy); +``` + +### 2. Test-Order generieren + +```javascript +const api = window.SkriftBackendAPI; + +const result = await api.generateOrder( + api.generateOrderNumber(), + [ + { + text: 'Test Brief', + font: 'tilda', + format: 'A4', + placeholders: {} + } + ], + [], + { + customer: { + type: 'business', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com' + } + } +); + +console.log('Order result:', result); +``` + +## Troubleshooting + +### Fehler: "Backend ist nicht erreichbar" + +**Lösung:** +1. Prüfe Backend-URL in WordPress-Einstellungen +2. Teste Health-Check: `curl https://backend.deine-domain.de/health` +3. Prüfe Nginx Proxy Manager Konfiguration +4. Prüfe Backend-Container: `docker compose logs -f` + +### Fehler: "CORS Error" + +**Lösung:** +Nginx Proxy Manager muss CORS-Header setzen: + +```nginx +# In Custom Nginx Configuration (Advanced Tab) +add_header Access-Control-Allow-Origin "https://deine-wordpress-domain.de" always; +add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; +add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; + +if ($request_method = 'OPTIONS') { + return 204; +} +``` + +### Fehler: "Order generation failed" + +**Lösung:** +1. Prüfe Backend-Logs: `docker compose logs -f` +2. Prüfe `/var/skrift-output` Verzeichnis existiert +3. Prüfe Fonts sind vorhanden in `/app/fonts` +4. Prüfe `.env` hat `SCRIPTALIZER_ERR_FREQUENCY=0` + +### Webhook wird nicht aufgerufen + +**Lösung:** +1. Prüfe Webhook-URL in WordPress-Einstellungen +2. Teste Webhook manuell mit curl +3. Prüfe N8N/Webhook-Service Logs +4. Webhook-Fehler werden ignoriert (soft fail) - Order wird trotzdem erstellt + +## Weitere Entwicklung + +### Preview-System (TODO) + +Aktuell wird direkt die finale Order generiert. Zukünftig: +1. Kunde füllt Schritt 1-4 aus +2. Preview generieren mit `/api/preview/batch` +3. Kunde sieht Vorschau der Briefe +4. Kunde bestätigt → Order finalisieren mit `/api/order/finalize` + +### PayPal-Integration (TODO) + +Für Privatkunden: +1. PayPal SDK laden +2. Order-ID an PayPal übergeben +3. Nach Zahlung Webhook empfangen +4. Backend-Order generieren +5. Weiterleitung + +## Support + +Bei Problemen: +1. Browser-Console öffnen (F12) +2. Logs prüfen (nach `[API]` oder `[Backend Integration]` filtern) +3. Backend-Logs prüfen: `docker compose logs -f` +4. WordPress Debug-Log prüfen: `wp-content/debug.log` + +## Checkliste nach Installation + +- [ ] Backend deployed und erreichbar +- [ ] Health-Check erfolgreich: `https://backend.domain.de/health` +- [ ] WordPress Admin-Einstellungen konfiguriert + - [ ] API URL gesetzt + - [ ] Order Webhook URL gesetzt (optional) + - [ ] Redirect URLs gesetzt +- [ ] Test-Bestellung generiert +- [ ] Dateien in `/var/skrift-output` erstellt +- [ ] Webhook wurde aufgerufen (falls konfiguriert) +- [ ] Keine durchgestrichenen Wörter in SVGs +- [ ] Handschrift-Variationen sichtbar (Wortabstände, Rotation) diff --git a/skrift-configurator/README.md b/skrift-configurator/README.md new file mode 100644 index 0000000..d3b0c84 --- /dev/null +++ b/skrift-configurator/README.md @@ -0,0 +1,318 @@ +# Skrift Konfigurator - WordPress Plugin + +Interaktiver Konfigurator für handgeschriebene Briefe, Postkarten und Einladungen. + +## Features + +- ✅ Multi-Step Konfigurator mit 6 Schritten +- ✅ B2B und B2C Workflows +- ✅ Dynamische Preisberechnung +- ✅ Gutschein-System +- ✅ Backend-Integration für SVG-Generierung +- ✅ Preview-System (in Entwicklung) +- ✅ Responsive Design +- ✅ Vollständig anpassbare Preise und Produkte + +## Installation + +1. Plugin-Ordner nach `wp-content/plugins/skrift-konfigurator/` kopieren +2. In WordPress: **Plugins → Installierte Plugins** +3. "Skrift Konfigurator" aktivieren +4. Zu **Einstellungen → Skrift Konfigurator** gehen +5. Einstellungen konfigurieren + +## Konfiguration + +### 1. Produkte + +Verwalte Namen, Beschreibungen und Basispreise für alle 5 Produkte: +- Businessbriefe +- Business Postkarten +- Follow-ups +- Einladungen +- Private Briefe + +### 2. Preise + +Konfiguriere Aufpreise für: +- Formate (A4 Upgrade) +- Versand (Direkt vs. Bulk) +- Umschläge (mit Adresse vs. Custom Text) +- Zusatzleistungen (Motiv-Upload, Design-Service, etc.) +- Schriftarten (Tilda, Alva, Ellie) + +### 3. Dynamische Preisformeln + +Erstelle Mengenrabatt-Formeln für Business und Privatkunden: + +**Beispiel Business:** +``` +if (q < 50) return 2.50; +if (q < 100) return 2.30; +if (q < 200) return 2.10; +return 1.90; +``` + +**Beispiel Privat:** +``` +if (q < 10) return 3.00; +if (q < 25) return 2.80; +return 2.50; +``` + +### 4. Backend-Verbindung + +**WICHTIG:** Für die SVG-Generierung! + +- **API URL:** `https://backend.deine-domain.de` +- **Order Webhook URL:** `https://n8n.deine-domain.de/webhook/order` (optional) +- **Redirect URLs:** Wohin nach Bestellung weitergeleitet wird + +Siehe [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) für Details. + +### 5. Gutscheine + +Erstelle Gutschein-Codes für Rabatte oder Testbestellungen. + +**Arten:** +- **Prozentual:** 10%, 20%, 50% +- **Festbetrag:** 5€, 10€, 20€ +- **Gratis:** 100% Rabatt + +**Einstellungen:** +- Einmalverwendung oder Mehrfachnutzung +- Aktiv/Inaktiv Toggle + +## Verwendung + +### Shortcode einfügen + +``` +[skrift_konfigurator] +``` + +Füge diesen Shortcode auf jeder Seite oder jedem Beitrag ein. + +### Mit URL-Parametern + +Direktlink zu einem Produkt: + +``` +https://deine-domain.de/konfigurator/?businessbriefe +https://deine-domain.de/konfigurator/?private-briefe +https://deine-domain.de/konfigurator/?einladungen +``` + +## Datei-Struktur + +``` +skrift-konfigurator/ +├── assets/ +│ ├── css/ +│ │ └── configurator.css # Styling +│ └── js/ +│ ├── configurator-app.js # Main App +│ ├── configurator-state.js # State Management +│ ├── configurator-ui.js # UI Rendering +│ ├── configurator-pricing.js # Price Calculation +│ ├── configurator-api.js # Backend API Client +│ └── configurator-backend-integration.js # Backend Integration +├── includes/ +│ ├── admin-settings.php # Admin Settings Page +│ └── admin-vouchers.php # Voucher Management +├── skrift-konfigurator.php # Main Plugin File +├── BACKEND_INTEGRATION.md # Backend Integration Guide +└── README.md # This file +``` + +## API Integration + +Das Plugin kommuniziert mit dem Node.js Backend über REST API. + +### Endpoints + +| Endpoint | Method | Beschreibung | +|----------|--------|--------------| +| `/health` | GET | Health-Check | +| `/api/preview/batch` | POST | Preview generieren | +| `/api/order/generate` | POST | Order erstellen | +| `/api/order/finalize` | POST | Order aus Preview finalisieren | + +Siehe [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) für vollständige API-Dokumentation. + +## Workflow + +### Business-Kunde + +``` +1. Produkt auswählen (Businessbriefe, Business Postkarten, Follow-ups) +2. Menge eingeben +3. Format wählen +4. Versand & Umschlag konfigurieren +5. Inhalt eingeben (Text) +6. Kundendaten eingeben +7. Prüfen & Bestellen + → Backend generiert SVG-Dateien + → Webhook wird aufgerufen + → Weiterleitung zu Danke-Seite +``` + +### Privat-Kunde + +``` +1. Produkt auswählen (Private Briefe, Einladungen) +2. Menge eingeben +3. Format wählen +4. Versand & Umschlag konfigurieren +5. Inhalt eingeben (Text + optional Motiv) +6. Kundendaten eingeben +7. Prüfen & Bestellen + → PayPal-Checkout (später) + → Backend generiert SVG-Dateien + → Webhook wird aufgerufen + → Weiterleitung zu Danke-Seite +``` + +## Entwicklung + +### Requirements + +- PHP 7.4+ +- WordPress 5.8+ +- Modern Browser mit ES6+ Support + +### JavaScript + +Das Plugin nutzt ES6 Modules und läuft ohne Build-Step. + +**State Management:** +- Reducer-Pattern (ähnlich Redux) +- Immutable State Updates +- Uni-directional Data Flow + +**UI Rendering:** +- Virtual DOM mit `h()` Helper +- Deklaratives Rendering +- Event-Delegation + +### Debugging + +Browser-Console öffnen (F12): + +```javascript +// State prüfen +console.log(window.currentState); + +// Backend API testen +const api = window.SkriftBackendAPI; +await api.healthCheck(); + +// Test-Order erstellen +await api.generateOrder( + api.generateOrderNumber(), + [{ text: 'Test', font: 'tilda', format: 'A4', placeholders: {} }], + [], + { customer: { type: 'business', firstName: 'Test' } } +); +``` + +## Deployment + +### 1. Plugin auf Server hochladen + +Via FTP, SSH oder WordPress Dashboard: +``` +wp-content/plugins/skrift-konfigurator/ +``` + +### 2. Backend deployen + +Siehe `Docker Backend/DEPLOYMENT.md` + +### 3. WordPress konfigurieren + +- Einstellungen → Skrift Konfigurator +- Backend-URL setzen +- Preise anpassen +- Gutscheine erstellen (optional) + +### 4. Seite erstellen + +- Neue Seite: "Konfigurator" +- Shortcode einfügen: `[skrift_konfigurator]` +- Veröffentlichen + +### 5. Testen + +- Produkt durchklicken +- Test-Bestellung aufgeben +- Prüfen ob Backend-Order erstellt wurde +- Prüfen ob Dateien in `/var/skrift-output/` erstellt wurden + +## Troubleshooting + +### Konfigurator wird nicht angezeigt + +**Lösung:** +- Shortcode korrekt? `[skrift_konfigurator]` +- JavaScript-Fehler in Console? (F12) +- Plugin aktiviert? + +### Backend-Verbindung fehlgeschlagen + +**Lösung:** +- Backend URL korrekt in Einstellungen? +- Backend erreichbar? `curl https://backend.domain.de/health` +- CORS konfiguriert? (Nginx Proxy Manager) + +### Preise werden falsch berechnet + +**Lösung:** +- Preise in Einstellungen prüfen +- Dynamische Formeln prüfen (Syntax) +- Console-Logs prüfen: `window.currentState.quote` + +### Gutschein funktioniert nicht + +**Lösung:** +- Gutschein ist aktiv? +- Gutschein noch nicht verwendet? (bei Einmalverwendung) +- Code korrekt geschrieben? (Case-sensitive!) + +## Changelog + +### Version 0.3.0 +- ✅ Backend-Integration implementiert +- ✅ API Client für Preview & Order +- ✅ Webhook-Support +- ✅ Redirect-URLs konfigurierbar + +### Version 0.2.0 +- ✅ Gutschein-System +- ✅ Dynamische Preisformeln +- ✅ Admin-Einstellungen erweitert + +### Version 0.1.0 +- ✅ Basis-Konfigurator +- ✅ 6 Schritte +- ✅ B2B und B2C Workflows + +## TODO + +- [ ] Preview-System vollständig integrieren +- [ ] PayPal-Integration für Privatkunden +- [ ] Email-Benachrichtigungen +- [ ] PDF-Export der Bestellung +- [ ] Admin-Dashboard für Bestellungen + +## Support + +Bei Fragen oder Problemen: +1. [BACKEND_INTEGRATION.md](./BACKEND_INTEGRATION.md) lesen +2. Browser-Console prüfen (F12) +3. Backend-Logs prüfen: `docker compose logs -f` +4. WordPress Debug-Log: `wp-content/debug.log` + +## Lizenz + +Proprietär - Alle Rechte vorbehalten diff --git a/skrift-configurator/assets/css/configurator.css b/skrift-configurator/assets/css/configurator.css new file mode 100644 index 0000000..c5950df --- /dev/null +++ b/skrift-configurator/assets/css/configurator.css @@ -0,0 +1,1463 @@ +/* Skrift Konfigurator Styles - Überarbeitet + Optimiert für besseren Frageprozess und UX */ + +.sk-configurator { + /* Farben */ + --sk-bg: #f7f7f8; + --sk-card: #ffffff; + --sk-text: #0f172a; + --sk-muted: rgba(15, 23, 42, 0.68); + --sk-border: rgba(15, 23, 42, 0.1); + --sk-border-strong: rgba(15, 23, 42, 0.16); + --sk-accent: #1e3a6a; + --sk-success: #059669; + --sk-warning: #f59e0b; + --sk-error: #dc2626; + + /* Radius & Spacing */ + --sk-radius: 8px; + --sk-gap: 18px; + + /* Typography */ + --sk-font-primary: var( + --e-global-typography-primary-font-family, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Arial, + sans-serif + ); + --sk-font-text: var( + --e-global-typography-text-font-family, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Arial, + sans-serif + ); + + font-family: var(--sk-font-text); + color: var(--sk-text); + background: var(--sk-bg); + + max-width: 1400px; + margin: 0 auto; + padding: var(--sk-gap); + + /* Scroll-Offset für festes Menü */ + scroll-margin-top: 120px; + scroll-padding-top: 120px; +} + +/* Layout Grid */ +.sk-configurator__layout { + display: grid; + grid-template-columns: minmax(0, 65fr) minmax(300px, 35fr); + gap: var(--sk-gap); + align-items: start; +} + +@media (max-width: 980px) { + .sk-configurator__layout { + grid-template-columns: 1fr; + } + + .sk-main { + max-width: 100%; + } +} + +/* Main Content Area */ +.sk-main { + display: flex; + flex-direction: column; + gap: var(--sk-gap); + min-width: 0; +} + +/* Sidebar Preview */ +.sk-side { + display: flex; + flex-direction: column; + gap: var(--sk-gap); + position: sticky; + top: 150px; +} + +/* Top Bar mit Preis */ +.sk-topbar { + background: var(--sk-card); + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + padding: 16px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.sk-price { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sk-price-label { + font-size: 12px; + color: var(--sk-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sk-price-value { + font-size: 24px; + font-weight: 750; + color: var(--sk-text); +} + +.sk-price-note { + font-size: 12px; + color: var(--sk-muted); + margin-top: 2px; +} + +/* Product Info */ +.sk-product-info { + display: flex; + align-items: center; + gap: 12px; +} + +.sk-product-icon { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--sk-accent); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; +} + +.sk-product-label { + font-size: 14px; + font-weight: 600; + color: var(--sk-text); +} + +/* Progress Stepper */ +.sk-stepper { + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; +} + +.sk-chip { + appearance: none; + border: 1px solid var(--sk-border); + background: white; + border-radius: 999px; + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.sk-chip:hover:not(:disabled) { + border-color: var(--sk-border-strong); + transform: translateY(-1px); +} + +.sk-chip.is-active { + border-color: var(--sk-accent); + background: var(--sk-accent); + color: white; + font-weight: 600; +} + +.sk-chip.is-complete { + border-color: var(--sk-success); + background: rgba(5, 150, 105, 0.1); + color: var(--sk-success); +} + +.sk-chip:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* Checkmark Icon für erledigte Steps */ +.sk-chip-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--sk-success); + color: white; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +/* Pfeil zwischen Steps - dünner Chevron-Pfeil */ +.sk-stepper-arrow { + color: var(--sk-muted); + font-size: 18px; + font-weight: 400; + user-select: none; + flex-shrink: 0; + opacity: 0.7; + margin: 0 2px; + line-height: 1; +} + +/* Cards */ +.sk-card { + background: var(--sk-card); + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + overflow: hidden; + margin-bottom: 16px; + max-width: 100%; +} + +.sk-card-head { + padding: 16px; + border-bottom: 1px solid rgba(15, 23, 42, 0.06); +} + +.sk-card-title { + font-size: 16px; + font-weight: 700; + color: var(--sk-text); + margin: 0; +} + +.sk-card-subtitle { + font-size: 14px; + color: var(--sk-muted); + margin-top: 4px; +} + +.sk-card-body { + padding: 16px; + max-width: 100%; + overflow: hidden; +} + +.sk-card-foot { + padding: 16px; + border-top: 1px solid rgba(15, 23, 42, 0.06); + background: rgba(15, 23, 42, 0.02); +} + +/* Selection Cards (Product Selection) */ +.sk-selection-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.sk-selection-card { + border: 2px solid var(--sk-border); + border-radius: var(--sk-radius); + padding: 16px; + cursor: pointer; + transition: all 0.2s ease; + background: white; + position: relative; +} + +.sk-selection-card:hover { + border-color: var(--sk-border-strong); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.sk-selection-card.is-selected { + border-color: var(--sk-accent); + background: rgba(30, 58, 106, 0.04); +} + +.sk-selection-card.is-selected::after { + content: "✓"; + position: absolute; + top: 12px; + right: 12px; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--sk-accent); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; +} + +.sk-selection-card-image { + width: 100%; + height: 160px; + background: rgba(15, 23, 42, 0.04); + border-radius: 6px; + margin-bottom: 12px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.sk-selection-card-image img { + max-width: 100%; + max-height: 100%; + object-fit: cover; +} + +.sk-selection-card-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 6px; +} + +.sk-selection-card-price { + font-size: 14px; + color: var(--sk-accent); + font-weight: 600; +} + +.sk-selection-card-desc { + font-size: 13px; + color: var(--sk-muted); + margin-top: 8px; + line-height: 1.5; +} + +/* Form Fields */ +.sk-field { + margin-bottom: 18px; + max-width: 100%; + overflow: hidden; +} + +.sk-field-label { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--sk-text); + margin-bottom: 8px; +} + +.sk-field-label.is-required::after { + content: "*"; + color: var(--sk-error); + margin-left: 4px; +} + +.sk-help { + font-size: 12px; + color: var(--sk-muted); + margin-top: 6px; + line-height: 1.5; +} + +.sk-input, +.sk-textarea, +.sk-select { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border-radius: var(--sk-radius); + border: 1px solid var(--sk-border); + background: white; + font-size: 14px; + font-family: var(--sk-font-text); + transition: all 0.2s ease; +} + +.sk-input:focus, +.sk-textarea:focus, +.sk-select:focus { + outline: none; + border-color: var(--sk-accent); + box-shadow: 0 0 0 3px rgba(30, 58, 106, 0.1); +} + +.sk-input.is-error, +.sk-textarea.is-error, +.sk-select.is-error, +.sk-input-error { + border-color: var(--sk-error); +} + +.sk-error-message { + color: var(--sk-error); + font-size: 14px; + margin-top: 8px; +} + +.sk-textarea { + resize: vertical; + min-height: 120px; + line-height: 1.5; +} + +/* Radio & Checkbox Options */ +.sk-options { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sk-option { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px; + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + cursor: pointer; + transition: all 0.2s ease; + background: white; + /* Verhindert Scroll-Verhalten bei Klick auf Label */ + scroll-margin: 0; + scroll-padding: 0; +} + +/* Verhindert dass Browser bei Checkbox/Radio Klick scrollt */ +.sk-option input[type="radio"], +.sk-option input[type="checkbox"] { + scroll-margin: 0; +} + +.sk-option:hover { + border-color: var(--sk-border-strong); + background: rgba(15, 23, 42, 0.02); +} + +.sk-option.is-selected { + border-color: var(--sk-accent); + background: rgba(30, 58, 106, 0.04); +} + +.sk-option input[type="radio"], +.sk-option input[type="checkbox"] { + margin-top: 2px; + flex-shrink: 0; +} + +.sk-option-content { + flex: 1; +} + +.sk-option-label { + font-size: 14px; + font-weight: 500; + color: var(--sk-text); + line-height: 1.5; +} + +.sk-option.is-selected .sk-option-label { + font-weight: 600; +} + +.sk-option-desc { + font-size: 13px; + color: var(--sk-muted); + margin-top: 4px; + line-height: 1.4; +} + +.sk-option-price { + font-size: 13px; + color: var(--sk-accent); + font-weight: 600; + margin-top: 4px; +} + +/* Tables */ +.sk-table-wrapper { + overflow: auto; + margin: 12px 0; + max-height: 500px; + max-width: 100%; + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + position: relative; + will-change: scroll-position; + -webkit-overflow-scrolling: touch; +} + +.sk-table { + width: 100%; + min-width: 600px; + border-collapse: separate; + border-spacing: 0; + background: white; +} + +.sk-table th, +.sk-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid rgba(15, 23, 42, 0.06); + white-space: nowrap; +} + +.sk-table th { + background: white; + font-size: 12px; + font-weight: 700; + color: var(--sk-text); + text-transform: uppercase; + letter-spacing: 0.5px; + position: sticky; + top: 0; + z-index: 10; + border-bottom: 2px solid rgba(15, 23, 42, 0.1); +} + +.sk-table td { + font-size: 13px; + vertical-align: middle; +} + +.sk-table tr:last-child td { + border-bottom: none; +} + +.sk-table-row-number { + width: 32px; + min-width: 32px; + max-width: 32px; + text-align: center; + font-weight: 600; + font-size: 13px; + color: var(--sk-muted); + background: white; + position: sticky; + left: 0; + z-index: 5; + padding: 8px 2px; +} + +td.sk-table-row-number { + width: 32px !important; + min-width: 32px !important; +} + +/* Summary-Tabellen (Key-Value Paare) - erste Spalte feste Breite */ +.sk-table td:first-child { + width: 180px; + min-width: 180px; + font-weight: 500; + color: var(--sk-muted); +} + +.sk-table th.sk-table-row-number { + background: white; + z-index: 15; +} + +.sk-table input { + min-width: 120px; + width: 100%; +} + +/* Buttons */ +.sk-btn { + appearance: none; + border: 1px solid var(--sk-border); + background: white; + border-radius: var(--sk-radius); + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: var(--sk-font-text); +} + +.sk-btn:hover:not(:disabled) { + border-color: var(--sk-border-strong); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.sk-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +.sk-btn-primary { + background: var(--sk-accent); + border-color: var(--sk-accent); + color: white; + font-weight: 700; +} + +.sk-btn-primary:hover:not(:disabled) { + background: #1a3258; + border-color: #1a3258; +} + +.sk-btn-secondary { + background: white; + border-color: var(--sk-border-strong); +} + +.sk-btn-large { + padding: 14px 24px; + font-size: 16px; +} + +/* Navigation */ +.sk-nav { + display: flex; + gap: 12px; + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--sk-border); +} + +.sk-nav button { + flex: 1; +} + +/* Preview Card */ +.sk-preview-card { + background: var(--sk-card); + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + padding: 16px; +} + +.sk-preview-title { + font-size: 14px; + font-weight: 700; + margin-bottom: 4px; +} + +.sk-preview-sub { + font-size: 12px; + color: var(--sk-muted); +} + +.sk-preview-box { + margin-top: 16px; + border: 2px dashed var(--sk-border); + border-radius: var(--sk-radius); + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.02); +} + +.sk-preview-placeholder { + text-align: center; + color: var(--sk-muted); + font-size: 13px; +} + +/* Alerts & Messages */ +.sk-alert { + padding: 12px 16px; + border-radius: var(--sk-radius); + margin: 12px 0; + font-size: 13px; + line-height: 1.5; +} + +.sk-alert-info { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + color: #1e40af; +} + +.sk-alert-warning { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + color: #92400e; +} + +.sk-alert-error { + background: rgba(220, 38, 38, 0.1); + border: 1px solid rgba(220, 38, 38, 0.3); + color: #991b1b; +} + +.sk-alert-success { + background: rgba(5, 150, 105, 0.1); + border: 1px solid rgba(5, 150, 105, 0.3); + color: #065f46; +} + +/* Utility Classes */ +.sk-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sk-inline { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.sk-sep { + height: 1px; + background: var(--sk-border); + margin: 16px 0; +} + +.sk-text-muted { + color: var(--sk-muted); +} + +.sk-text-small { + font-size: 12px; +} + +.sk-hidden { + display: none; +} + +/* Read-only Tabellenzellen */ +.sk-table-readonly-header { + background: rgba(156, 163, 175, 0.15) !important; + color: var(--sk-muted) !important; +} + +.sk-table-readonly-cell { + background: transparent; +} + +.sk-table-readonly-cell input { + background: rgba(156, 163, 175, 0.12); + color: var(--sk-muted); + cursor: not-allowed; +} + +/* Modal */ +.sk-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 20px; + overflow-y: auto; + font-family: var( + --sk-font-text, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Arial, + sans-serif + ); +} + +.sk-modal-content { + background: white; + border-radius: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + max-width: 95vw; + max-height: 90vh; + display: flex; + flex-direction: column; + width: 100%; + margin: auto; +} + +.sk-modal-header { + padding: 20px 24px; + border-bottom: 1px solid rgba(15, 23, 42, 0.1); + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.sk-modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #0f172a; + font-family: var( + --sk-font-primary, + system-ui, + -apple-system, + Segoe UI, + Roboto, + Arial, + sans-serif + ); +} + +.sk-modal-close { + appearance: none; + background: none; + border: none; + font-size: 32px; + line-height: 1; + cursor: pointer; + color: rgba(15, 23, 42, 0.68); + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.sk-modal-close:hover { + background: rgba(0, 0, 0, 0.05); + color: #0f172a; +} + +.sk-modal-body { + padding: 24px; + overflow-y: auto; + flex: 1; + color: #0f172a; +} + +.sk-table-wrapper-modal { + overflow: auto; + margin: 12px 0; + max-width: 100%; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 8px; + position: relative; + max-height: calc(90vh - 300px); + will-change: scroll-position; + -webkit-overflow-scrolling: touch; +} + +.sk-table-wrapper-modal .sk-table { + min-width: 800px; +} + +/* Mobile Preview Container - standardmäßig versteckt */ +.sk-preview-mobile { + display: none; +} + +/* Mobile Contact Card - standardmäßig versteckt */ +.sk-contact-card-mobile { + display: none; +} + +/* Responsive - Tablet */ +@media (max-width: 980px) { + .sk-configurator { + padding: 12px; + } + + /* Auf Tablet/Mobile: Sidebar als eigener Block */ + .sk-configurator__layout { + display: flex; + flex-direction: column; + } + + /* Desktop-Sidebar auf Mobile ausblenden */ + .sk-side { + display: none; + } + + /* Mobile Preview anzeigen (nach Fragen, vor Weiter-Button) */ + .sk-preview-mobile { + display: block; + margin-bottom: var(--sk-gap); + } + + /* Mobile Contact Card anzeigen (nach Weiter-Button) */ + .sk-contact-card-mobile { + display: block; + margin-top: var(--sk-gap); + } + + .sk-main { + order: 1; + } + + .sk-stepper { + gap: 8px; + justify-content: center; + } + + .sk-chip { + padding: 6px 12px; + font-size: 12px; + } + + .sk-stepper-arrow { + font-size: 12px; + margin: 0 4px; + } +} + +/* Responsive - Mobile */ +@media (max-width: 640px) { + .sk-configurator { + padding: 8px; + } + + .sk-selection-grid { + grid-template-columns: 1fr; + } + + .sk-nav { + flex-direction: column; + } + + /* Stepper auf Mobile: Horizontal scrollbar mit Padding links */ + .sk-stepper { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow-x: auto; + gap: 4px; + padding: 4px 8px 8px 8px; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + margin: 0 -8px; /* Negative margin to extend to edges */ + width: calc(100% + 16px); + } + + .sk-stepper::-webkit-scrollbar { + height: 4px; + } + + .sk-stepper::-webkit-scrollbar-thumb { + background: var(--sk-border-strong); + border-radius: 2px; + } + + .sk-chip { + flex-shrink: 0; + padding: 5px 8px; + font-size: 10px; + white-space: nowrap; + } + + .sk-stepper-arrow { + flex-shrink: 0; + font-size: 9px; + margin: 0 1px; + } + + /* Mobile Layout: Vorschau zwischen Form und Nav */ + .sk-sidebar { + display: none; /* Desktop Sidebar verstecken */ + } + + .sk-preview-mobile { + display: block !important; + order: 2; /* Nach Form (order: 1), vor Nav */ + margin-bottom: 16px; + } + + /* "Noch Fragen" Card nach dem Button */ + .sk-contact-card-mobile { + order: 4; /* Nach Nav (order: 3) */ + margin-top: 16px; + } + + .sk-card-body { + padding: 12px; + } + + .sk-card-head { + padding: 12px; + } + + .sk-topbar { + padding: 12px; + flex-direction: column; + align-items: flex-start; + } + + .sk-price-value { + font-size: 20px; + } + + .sk-option { + padding: 10px; + } + + .sk-modal-content { + max-width: 100%; + max-height: 100%; + border-radius: 0; + } + + .sk-modal { + padding: 0; + } + + .sk-modal-header { + padding: 16px; + } + + .sk-modal-body { + padding: 16px; + } + + .sk-preview-box { + min-height: 200px; + } + + /* Preview Navigation auf Mobile */ + .sk-preview-navigation { + justify-content: center !important; + } + + .sk-preview-navigation button { + padding: 10px 16px !important; + font-size: 18px !important; + } +} + +/* Sehr kleine Screens */ +@media (max-width: 380px) { + .sk-chip { + padding: 5px 8px; + font-size: 10px; + } + + .sk-stepper-arrow { + display: none; + } + + .sk-card-title { + font-size: 14px; + } + + .sk-option-label { + font-size: 13px; + } + + .sk-option-desc { + font-size: 12px; + } +} + +/* Table Mobile Overflow Fix */ +.sk-card-body { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.sk-table { + min-width: 100%; +} + +@media (max-width: 640px) { + .sk-table { + font-size: 13px; + } + + .sk-table th, + .sk-table td { + padding: 8px 10px; + white-space: nowrap; + } +} + +/* noPrice Mode - Versteckt alle Preisanzeigen */ +.sk-configurator.sk-no-price .sk-price, +.sk-configurator.sk-no-price .sk-topbar .sk-price, +.sk-configurator.sk-no-price .sk-option-price, +.sk-configurator.sk-no-price .sk-selection-card-price, +.sk-configurator.sk-no-price .sk-price-card { + display: none !important; +} + +/* Mobile Product Cards - Kompakte Darstellung (Bild links, Text rechts) */ +@media (max-width: 640px) { + .sk-selection-grid { + gap: 10px; + } + + .sk-selection-card { + display: flex; + flex-direction: row; + gap: 12px; + padding: 12px; + align-items: flex-start; + } + + .sk-selection-card-image { + width: 80px; + min-width: 80px; + height: 80px; + margin-bottom: 0; + border-radius: 6px; + flex-shrink: 0; + } + + .sk-selection-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .sk-selection-card-content { + flex: 1; + min-width: 0; + } + + .sk-selection-card-title { + font-size: 14px; + margin-bottom: 4px; + } + + .sk-selection-card-price { + font-size: 13px; + } + + .sk-selection-card-desc { + font-size: 12px; + margin-top: 4px; + line-height: 1.4; + /* Beschreibung auf 2 Zeilen begrenzen */ + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + /* Checkmark für ausgewählte Karte */ + .sk-selection-card.is-selected::after { + top: 8px; + right: 8px; + width: 20px; + height: 20px; + font-size: 12px; + } +} + +/* ========== Validierungs-Overlay (Ladeanimation) ========== */ +.sk-validation-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.92); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10000; + gap: 20px; +} + +.sk-validation-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--sk-border); + border-top-color: var(--sk-accent); + border-radius: 50%; + animation: sk-spin 1s linear infinite; +} + +@keyframes sk-spin { + to { transform: rotate(360deg); } +} + +.sk-validation-text { + font-size: 16px; + color: var(--sk-text); + text-align: center; +} + +/* ========== Overflow-Warnung ========== */ +.sk-overflow-warning { + background: #fef2f2; + border: 1px solid var(--sk-error); + border-radius: var(--sk-radius); + padding: 16px; + margin-top: 16px; +} + +.sk-overflow-warning-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--sk-error); + margin-bottom: 12px; +} + +.sk-overflow-warning-title::before { + content: "⚠️"; +} + +.sk-overflow-warning-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sk-overflow-warning-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: white; + border-radius: 4px; + font-size: 14px; +} + +.sk-overflow-warning-name { + font-weight: 500; +} + +.sk-overflow-warning-lines { + color: var(--sk-error); + font-weight: 600; +} + +.sk-overflow-warning-hint { + margin-top: 12px; + font-size: 13px; + color: var(--sk-muted); +} + +.sk-overflow-warning-more { + background: transparent; + color: var(--sk-muted); + font-style: italic; +} + +/* ========== Validierungs-Fehler ========== */ +.sk-validation-error { + background: #fef2f2; + border: 1px solid var(--sk-error); + border-radius: var(--sk-radius); + padding: 16px; + margin-bottom: 16px; +} + +.sk-validation-error-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--sk-error); + margin-bottom: 8px; +} + +.sk-validation-error-title::before { + content: "⚠️"; +} + +.sk-validation-error-message { + margin: 0 0 8px 0; + color: var(--sk-text); +} + +.sk-validation-error-hint { + margin: 0; + font-size: 13px; + color: var(--sk-muted); +} + +/* ========== PREISRECHNER SIDEBAR ========== */ + +.sk-calc-sidebar-card { + background: var(--sk-card); + border: 1px solid var(--sk-border); + border-radius: var(--sk-radius); + overflow: hidden; +} + +.sk-calc-main-price { + text-align: center; + padding: 24px 20px; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-bottom: 1px solid var(--sk-border); +} + +.sk-calc-main-price__value { + font-size: 42px; + font-weight: 700; + color: var(--sk-text); + line-height: 1; +} + +.sk-calc-main-price__label { + font-size: 14px; + color: var(--sk-muted); + margin-top: 4px; +} + +.sk-calc-main-price__note { + font-size: 12px; + color: var(--sk-muted); + margin-top: 8px; +} + +.sk-calc-details { + padding: 16px 20px; +} + +.sk-calc-price-table { + width: 100%; + border-collapse: collapse; +} + +.sk-calc-price-table td { + padding: 8px 0; + font-size: 14px; + border: none; +} + +.sk-calc-price-table td:first-child { + color: var(--sk-muted); + font-weight: 400; + width: auto; + min-width: 0; +} + +.sk-calc-price-table td:last-child { + text-align: right; + font-weight: 500; + color: var(--sk-text); +} + +.sk-calc-price-table tr:not(:last-child) td { + border-bottom: 1px solid var(--sk-border); +} + +.sk-calc-note { + font-size: 12px; + color: var(--sk-muted); + padding: 8px 0; + margin-top: 4px; +} + +.sk-calc-note--info { + background: rgba(59, 130, 246, 0.08); + border-radius: 6px; + padding: 10px 12px; + color: #1e40af; +} + +.sk-calc-setup-section { + padding: 16px 20px; + border-top: 1px solid var(--sk-border); + background: rgba(15, 23, 42, 0.02); +} + +.sk-calc-section-label { + font-size: 12px; + font-weight: 600; + color: var(--sk-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.sk-calc-total-section { + padding: 16px 20px; + border-top: 1px solid var(--sk-border); + background: transparent; +} + +.sk-calc-total-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 15px; +} + +.sk-calc-total-row span:first-child { + color: var(--sk-text); + font-weight: 500; +} + +.sk-calc-total-value { + font-weight: 700; + color: var(--sk-accent); + font-size: 18px; +} + +.sk-calc-cta-btn, +.sk-calc-cta-btn:link, +.sk-calc-cta-btn:visited { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 16px; + padding: 14px 20px; + background: var(--sk-accent); + color: #ffffff !important; + border: none; + border-radius: var(--sk-radius); + font-size: 15px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; +} + +.sk-calc-cta-btn:hover, +.sk-calc-cta-btn:active { + background: #1a3258; + color: #ffffff !important; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(30, 58, 106, 0.3); +} + +.sk-calc-cta-arrow { + font-size: 18px; +} + +/* Preisrechner max-width */ +.sk-configurator[data-skrift-preisrechner] { + max-width: 1200px; +} + +/* Preisrechner Mobile Sidebar */ +.sk-calc-mobile-sidebar { + display: none; +} + +@media (max-width: 980px) { + .sk-calc-mobile-sidebar { + display: block; + margin-top: var(--sk-gap); + } +} diff --git a/skrift-configurator/assets/js/configurator-api.js b/skrift-configurator/assets/js/configurator-api.js new file mode 100644 index 0000000..a7ff15d --- /dev/null +++ b/skrift-configurator/assets/js/configurator-api.js @@ -0,0 +1,392 @@ +/** + * Skrift Backend API Client + * Kommunikation mit dem Node.js Backend über WordPress Proxy + * Der API-Token wird serverseitig gehandhabt und ist nicht im Frontend exponiert + */ + +class SkriftBackendAPI { + constructor() { + // WordPress REST API URL für den Proxy + this.restUrl = window.SkriftConfigurator?.restUrl || '/wp-json/'; + // API Key für WordPress REST API Authentifizierung + this.apiKey = window.SkriftConfigurator?.apiKey || ''; + // WordPress Nonce für CSRF-Schutz + this.nonce = window.SkriftConfigurator?.nonce || ''; + // Direkte Backend-URL nur für Preview-Bilder (read-only) + this.backendUrl = window.SkriftConfigurator?.settings?.backend_connection?.api_url || ''; + // Alias für Kompatibilität mit PreviewManager + this.baseURL = this.backendUrl; + this.sessionId = null; + this.previewCache = new Map(); + } + + /** + * Gibt Standard-Headers für WordPress REST API zurück + */ + getHeaders() { + const headers = { + 'Content-Type': 'application/json', + 'X-WP-Nonce': this.nonce, + }; + + if (this.apiKey) { + headers['X-Skrift-API-Key'] = this.apiKey; + } + + return headers; + } + + /** + * Generiert eine eindeutige Session-ID für Preview-Caching + */ + generateSessionId() { + return `session-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + /** + * Health-Check: Prüft ob Backend erreichbar ist (über WordPress Proxy) + */ + async healthCheck() { + try { + const response = await fetch(`${this.restUrl}skrift/v1/proxy/health`, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`Health check failed: ${response.statusText}`); + } + + const data = await response.json(); + return data.status === 'ok'; + } catch (error) { + console.error('[API] Health check failed:', error); + return false; + } + } + + /** + * Preview Batch: Generiert eine Vorschau von Briefen (über WordPress Proxy) + * Briefe und Umschläge werden in derselben Session gespeichert + */ + async generatePreviewBatch(letters, options = {}) { + try { + // SessionId nur generieren wenn noch keine existiert oder explizit angefordert + // So bleiben Briefe und Umschläge in derselben Session + if (!this.sessionId || options.newSession) { + this.sessionId = this.generateSessionId(); + console.log('[API] New session created:', this.sessionId); + } else { + console.log('[API] Reusing existing session:', this.sessionId); + } + + const requestBody = { + sessionId: this.sessionId, + letters: letters.map(letter => ({ + index: letter.index, + text: letter.text, + font: letter.font || 'tilda', + format: letter.format || 'A4', + placeholders: letter.placeholders || {}, + type: letter.type || 'letter', + envelopeType: letter.envelopeType || 'recipient', + envelope: letter.envelope || null, + })), + }; + + const response = await fetch(`${this.restUrl}skrift/v1/proxy/preview/batch`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.json(); + + if (response.status === 429 && error.retryAfter) { + const err = new Error(error.error || 'Rate limit exceeded'); + err.retryAfter = error.retryAfter; + err.statusCode = 429; + throw err; + } + + throw new Error(error.error || error.message || `Preview generation failed: ${response.statusText}`); + } + + const data = await response.json(); + + // Session-ID vom Backend übernehmen (falls anders als gesendet) + if (data.sessionId) { + this.sessionId = data.sessionId; + } + + const previews = data.files ? data.files.map((file, index) => ({ + index: file.index !== undefined ? file.index : index, + url: file.url || file.path, + format: file.format, + pages: file.pages || 1, + lineCount: file.lineCount, + lineLimit: file.lineLimit, + overflow: file.overflow, + recipientName: file.recipientName, + })) : []; + + previews.forEach((preview, index) => { + this.previewCache.set(`${this.sessionId}-${index}`, preview); + }); + + return { + success: true, + sessionId: this.sessionId, + previews: previews, + batchInfo: data.batchInfo, + hasOverflow: data.hasOverflow || false, + overflowFiles: data.overflowFiles || [], + }; + } catch (error) { + console.error('[API] Preview batch error:', error); + return { + success: false, + error: error.message, + }; + } + } + + /** + * Get Preview: Ruft eine einzelne Preview-URL ab + * Hinweis: Diese Methode wird aktuell nicht verwendet, da Preview-URLs direkt vom Backend kommen + */ + async getPreviewUrl(sessionId, index) { + try { + const cacheKey = `${sessionId}-${index}`; + + if (this.previewCache.has(cacheKey)) { + return this.previewCache.get(cacheKey).url; + } + + // Über WordPress Proxy abrufen + const response = await fetch(`${this.restUrl}skrift/v1/proxy/preview/${sessionId}/${index}`, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`Preview not found: ${response.statusText}`); + } + + const svgText = await response.text(); + // Sicheres Base64-Encoding für Unicode + const dataUrl = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgText)))}`; + + return dataUrl; + } catch (error) { + console.error('[API] Get preview URL error:', error); + throw error; + } + } + + /** + * Finalize Order: Finalisiert eine Bestellung aus dem Preview-Cache (über WordPress Proxy) + */ + async finalizeOrder(sessionId, orderNumber, metadata = {}) { + try { + const requestBody = { + sessionId: sessionId, + orderNumber: orderNumber, + metadata: { + customer: metadata.customer || {}, + orderDate: metadata.orderDate || new Date().toISOString(), + ...metadata, + }, + }; + + const response = await fetch(`${this.restUrl}skrift/v1/proxy/order/finalize`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || `Order finalization failed: ${response.statusText}`); + } + + const data = await response.json(); + + return { + success: true, + orderNumber: data.orderNumber, + path: data.path, + files: data.files, + envelopesGenerated: data.envelopesGenerated || 0, + }; + } catch (error) { + console.error('[API] Finalize order error:', error); + return { + success: false, + error: error.message, + }; + } + } + + /** + * Generate Order: Generiert eine Bestellung ohne Preview (direkt, über WordPress Proxy) + * Backend erwartet alle Dokumente (Briefe + Umschläge) im letters-Array mit type-Property + */ + async generateOrder(orderNumber, letters, envelopes = [], metadata = {}) { + try { + // Letters vorbereiten + const preparedLetters = letters.map((letter, index) => ({ + index: letter.index !== undefined ? letter.index : index, + text: letter.text, + font: letter.font || 'tilda', + format: letter.format || 'A4', + placeholders: letter.placeholders || {}, + type: 'letter', + })); + + // Envelopes vorbereiten und anhängen + const preparedEnvelopes = envelopes.map((envelope, index) => ({ + index: envelope.index !== undefined ? envelope.index : index, + text: envelope.text || '', + font: envelope.font || 'tilda', + format: envelope.format || 'DIN_LANG', + placeholders: envelope.placeholders || {}, + type: 'envelope', + envelopeType: envelope.envelopeType || 'recipient', + })); + + // Alle Dokumente in einem Array für Backend + const allDocuments = [...preparedLetters, ...preparedEnvelopes]; + + const requestBody = { + orderNumber: orderNumber, + letters: allDocuments, + metadata: { + customer: metadata.customer || {}, + orderDate: metadata.orderDate || new Date().toISOString(), + ...metadata, + }, + }; + + const response = await fetch(`${this.restUrl}skrift/v1/proxy/order/generate`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || `Order generation failed: ${response.statusText}`); + } + + const data = await response.json(); + + return { + success: true, + orderNumber: data.orderNumber, + path: data.path, + files: data.files, + summary: data.summary, + }; + } catch (error) { + console.error('[API] Generate order error:', error); + return { + success: false, + error: error.message, + }; + } + } + + /** + * Generate Order Number: Holt fortlaufende Bestellnummer vom WordPress-Backend + * Schema: S-JAHR-MONAT-TAG-fortlaufendeNummer (z.B. S-2026-01-12-001) + */ + async generateOrderNumber() { + try { + const response = await fetch(`${this.restUrl}skrift/v1/order/generate-number`, { + method: 'POST', + headers: this.getHeaders(), + }); + + if (!response.ok) { + throw new Error(`Failed to generate order number: ${response.statusText}`); + } + + const data = await response.json(); + return data.orderNumber; + } catch (error) { + console.error('[API] Failed to generate order number from WP:', error); + // Fallback: Lokale Generierung (sollte nicht passieren) + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const random = String(Math.floor(Math.random() * 1000)).padStart(3, '0'); + return `S-${year}-${month}-${day}-${random}`; + } + } + + /** + * Upload Motif: Lädt ein Motiv-Bild hoch (über WordPress Proxy) + * @param {File} file - Die hochzuladende Datei + * @param {string} orderNumber - Die Bestellnummer für die Dateinamenszuordnung + * @returns {Promise<{success: boolean, filename?: string, url?: string, error?: string}>} + */ + async uploadMotif(file, orderNumber) { + try { + const formData = new FormData(); + formData.append('motif', file); + formData.append('orderNumber', orderNumber || ''); + + const response = await fetch(`${this.restUrl}skrift/v1/proxy/motif/upload`, { + method: 'POST', + headers: { + 'X-WP-Nonce': this.nonce, + ...(this.apiKey ? { 'X-Skrift-API-Key': this.apiKey } : {}), + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || `Motif upload failed: ${response.statusText}`); + } + + const data = await response.json(); + return { + success: true, + filename: data.filename, + url: data.url, + path: data.path, + }; + } catch (error) { + console.error('[API] Motif upload error:', error); + return { + success: false, + error: error.message, + }; + } + } + + /** + * Clear Preview Cache: Löscht den Preview-Cache und setzt Session zurück + */ + clearPreviewCache() { + this.previewCache.clear(); + this.sessionId = null; // Wird beim nächsten Preview-Aufruf neu generiert + } + + /** + * Start New Session: Erzwingt eine neue Session für den nächsten Preview-Aufruf + */ + startNewSession() { + this.sessionId = null; + this.previewCache.clear(); + } +} + +// Globale Instanz exportieren +window.SkriftBackendAPI = new SkriftBackendAPI(); + +export default SkriftBackendAPI; diff --git a/skrift-configurator/assets/js/configurator-app.js b/skrift-configurator/assets/js/configurator-app.js new file mode 100644 index 0000000..be4ce3a --- /dev/null +++ b/skrift-configurator/assets/js/configurator-app.js @@ -0,0 +1,160 @@ +/* global SkriftConfigurator */ +import { + createInitialState, + deriveContextFromUrl, + reducer, + STEPS, +} from "./configurator-state.js?ver=0.3.0"; +import { render, showValidationOverlay, hideValidationOverlay, showOverflowWarning, showValidationError, flushAllTables } from "./configurator-ui.js?ver=0.3.0"; +import './configurator-api.js'; // Backend API initialisieren +import PreviewManager from './configurator-preview-manager.js'; // Preview Management + +(function boot() { + const root = document.querySelector('[data-skrift-konfigurator="1"]'); + if (!root) return; + + // Cleanup bei Navigation (SPA) oder Seiten-Unload + const cleanupHandlers = []; + + const cleanup = () => { + flushAllTables(); // Tabellen-Daten speichern vor Cleanup/Seiten-Unload + cleanupHandlers.forEach(handler => handler()); + cleanupHandlers.length = 0; + if (window.envelopePreviewManager) { + window.envelopePreviewManager.destroy(); + } + if (window.contentPreviewManager) { + window.contentPreviewManager.destroy(); + } + }; + + // Cleanup bei Seiten-Unload + window.addEventListener('beforeunload', cleanup); + cleanupHandlers.push(() => window.removeEventListener('beforeunload', cleanup)); + + const ctx = deriveContextFromUrl(window.location.search); + let state = createInitialState(ctx); + + const dom = { + topbar: document.getElementById("sk-topbar"), + stepper: document.getElementById("sk-stepper"), + form: document.getElementById("sk-form"), + prev: document.getElementById("sk-prev"), + next: document.getElementById("sk-next"), + preview: document.getElementById("sk-preview"), + previewMobile: document.getElementById("sk-preview-mobile"), + contactMobile: document.getElementById("sk-contact-mobile"), + }; + + // Preview Manager initialisieren + const api = window.SkriftBackendAPI; + if (api) { + window.envelopePreviewManager = new PreviewManager(api); + window.contentPreviewManager = new PreviewManager(api); + } + + const dispatch = (action) => { + // Scroll-Position VOR dem State-Update speichern + const scrollY = window.scrollY; + const scrollX = window.scrollX; + + state = reducer(state, action); + render({ state, dom, dispatch }); + + // Scroll-Position NACH dem Render wiederherstellen + // Nur bei Actions die NICHT den Step wechseln (Navigation) + const navigationActions = ['NAV_NEXT', 'NAV_PREV', 'SET_STEP']; + if (!navigationActions.includes(action.type)) { + // requestAnimationFrame stellt sicher, dass das DOM komplett gerendert ist + requestAnimationFrame(() => { + window.scrollTo(scrollX, scrollY); + }); + } + }; + + // Event-Handler mit Cleanup-Tracking + const prevClickHandler = () => { + flushAllTables(); // Tabellen-Daten speichern vor Navigation + dispatch({ type: "NAV_PREV" }); + }; + + // Next-Handler mit Validierung bei Content-Step + const nextClickHandler = async () => { + flushAllTables(); // Tabellen-Daten speichern vor Navigation + + // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure + const currentState = window.currentGlobalState || state; + + // Bei Content-Step: Textlänge validieren und alle Previews generieren + if (currentState.step === STEPS.CONTENT) { + const previewManager = window.contentPreviewManager; + if (previewManager) { + showValidationOverlay(); + try { + const validation = await previewManager.validateTextLength(currentState); + + if (!validation.valid) { + hideValidationOverlay(); + // Bei Overflow: Warnung anzeigen + if (validation.overflowFiles && validation.overflowFiles.length > 0) { + showOverflowWarning(validation.overflowFiles, dom.form); + } else if (validation.error) { + // Bei Fehler (z.B. keine Anfragen mehr): Fehlermeldung anzeigen + showValidationError(validation.error, dom.form); + } + return; // Nicht weiter navigieren + } + + // Nach erfolgreicher Validierung: Umschlag-Previews generieren (gleiche Session) + // So sind alle Dokumente im Cache wenn die Bestellung finalisiert wird + const envelopeManager = window.envelopePreviewManager; + if (envelopeManager && currentState.answers?.envelope === true) { + try { + envelopeManager.previewCount = parseInt(currentState.answers?.quantity) || 1; + await envelopeManager.loadAllPreviews(currentState, true, true); + console.log('[App] Envelope previews generated for cache'); + } catch (envError) { + console.error('[App] Envelope preview generation failed:', envError); + // Nicht blockieren - Umschläge sind nicht kritisch für Navigation + } + } + + hideValidationOverlay(); + } catch (error) { + hideValidationOverlay(); + console.error('[App] Validation error:', error); + showValidationError(error.message || 'Validierung fehlgeschlagen', dom.form); + return; // Nicht weiter navigieren + } + } + } + dispatch({ type: "NAV_NEXT" }); + }; + + dom.prev.addEventListener("click", prevClickHandler); + dom.next.addEventListener("click", nextClickHandler); + cleanupHandlers.push(() => { + dom.prev.removeEventListener("click", prevClickHandler); + dom.next.removeEventListener("click", nextClickHandler); + }); + + // Keyboard Navigation für Previews (nur innerhalb des aktuellen Batches) + const keydownHandler = (e) => { + if (window.envelopePreviewManager && window.envelopePreviewManager.currentBatchPreviews.length > 0) { + window.envelopePreviewManager.handleKeyboardNavigation(e, state, dom.preview, true); + } + if (window.contentPreviewManager && window.contentPreviewManager.currentBatchPreviews.length > 0) { + window.contentPreviewManager.handleKeyboardNavigation(e, state, dom.preview, false); + } + }; + + document.addEventListener('keydown', keydownHandler); + cleanupHandlers.push(() => document.removeEventListener('keydown', keydownHandler)); + + render({ state, dom, dispatch }); + + // Konfigurator sichtbar machen nachdem erster Render abgeschlossen ist + requestAnimationFrame(() => { + root.classList.add('sk-ready'); + }); +})(); diff --git a/skrift-configurator/assets/js/configurator-backend-integration.js b/skrift-configurator/assets/js/configurator-backend-integration.js new file mode 100644 index 0000000..93a0155 --- /dev/null +++ b/skrift-configurator/assets/js/configurator-backend-integration.js @@ -0,0 +1,584 @@ +/** + * Backend Integration für Skrift Konfigurator + * Erweitert handleOrderSubmit um Backend-API Calls + */ + +import SkriftBackendAPI from './configurator-api.js'; +import { preparePlaceholdersForIndex } from './configurator-utils.js'; + +/** + * Bereitet Letter-Daten für Backend vor + */ +function prepareLettersForBackend(state) { + const letters = []; + const quantity = parseInt(state.answers?.quantity) || 1; + + // Haupttext + const mainText = state.answers?.letterText || state.answers?.text || state.answers?.briefText || ''; + const font = state.answers?.font || 'tilda'; + const format = state.answers?.format || 'A4'; + + // Für jede Kopie einen Letter-Eintrag erstellen mit individuellen Platzhaltern + for (let i = 0; i < quantity; i++) { + const placeholders = preparePlaceholdersForIndex(state, i); + + letters.push({ + index: i, + text: mainText, + font: mapFontToBackend(font), + format: mapFormatToBackend(format), + placeholders: placeholders, + type: 'letter', + }); + } + + return letters; +} + +/** + * Bereitet Envelope-Daten für Backend vor + */ +function prepareEnvelopesForBackend(state) { + const envelopes = []; + // envelope ist ein boolean (true/false), nicht 'yes'/'no' + const hasEnvelope = state.answers?.envelope === true; + + console.log('[Backend Integration] prepareEnvelopesForBackend:', { + envelope: state.answers?.envelope, + hasEnvelope, + envelopeMode: state.answers?.envelopeMode, + }); + + if (!hasEnvelope) { + return envelopes; + } + + const quantity = parseInt(state.answers?.quantity) || 1; + const envelopeMode = state.answers?.envelopeMode || 'recipientData'; + const format = state.answers?.format || 'A4'; + const font = state.answers?.envelopeFont || state.answers?.font || 'tilda'; + + // Envelope Format bestimmen + const envelopeFormat = format === 'a4' ? 'DIN_LANG' : 'C6'; + + if (envelopeMode === 'recipientData') { + // Empfängeradresse-Modus: Ein Envelope pro Brief mit individuellen Empfängerdaten + for (let i = 0; i < quantity; i++) { + const placeholders = preparePlaceholdersForIndex(state, i); + const recipient = state.recipientRows?.[i] || {}; + + // Umschlagtext aus Empfängerdaten zusammenbauen + const lines = []; + const fullName = `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(); + if (fullName) lines.push(fullName); + + const streetLine = `${recipient.street || ''} ${recipient.houseNumber || ''}`.trim(); + if (streetLine) lines.push(streetLine); + + const location = `${recipient.zip || ''} ${recipient.city || ''}`.trim(); + if (location) lines.push(location); + + if (recipient.country && recipient.country !== 'Deutschland') { + lines.push(recipient.country); + } + + envelopes.push({ + index: i, + text: lines.join('\n'), + font: mapFontToBackend(font), + format: envelopeFormat, + placeholders: placeholders, + type: 'envelope', + envelopeType: 'recipient', + }); + } + } else if (envelopeMode === 'customText') { + // Custom Text Modus mit Platzhaltern + const customText = state.answers?.envelopeCustomText || ''; + + for (let i = 0; i < quantity; i++) { + const placeholders = preparePlaceholdersForIndex(state, i); + + envelopes.push({ + index: i, + text: customText, + font: mapFontToBackend(font), + format: envelopeFormat, + placeholders: placeholders, + type: 'envelope', + envelopeType: 'custom', + }); + } + } + + return envelopes; +} + +// Hinweis: preparePlaceholdersForIndex ist jetzt in configurator-utils.js + +/** + * Mapped Frontend-Font zu Backend-Font + */ +function mapFontToBackend(frontendFont) { + const fontMap = { + 'tilda': 'tilda', + 'alva': 'alva', + 'ellie': 'ellie', + // Füge weitere Mappings hinzu falls nötig + }; + + return fontMap[frontendFont] || 'tilda'; +} + +/** + * Mapped Frontend-Format zu Backend-Format + */ +function mapFormatToBackend(frontendFormat) { + const formatMap = { + 'a4': 'A4', + 'a6p': 'A6_PORTRAIT', + 'a6l': 'A6_LANDSCAPE', + 'A4': 'A4', + 'A6_PORTRAIT': 'A6_PORTRAIT', + 'A6_LANDSCAPE': 'A6_LANDSCAPE', + }; + + return formatMap[frontendFormat] || 'A4'; +} + +/** + * Ermittelt das Umschlag-Format basierend auf Brief-Format + */ +function getEnvelopeFormat(letterFormat) { + const format = String(letterFormat).toLowerCase(); + if (format === 'a4') return 'DIN_LANG'; + if (format === 'a6p' || format === 'a6l') return 'C6'; + return 'DIN_LANG'; +} + +/** + * Formatiert Format für lesbare Ausgabe + */ +function formatFormatLabel(format) { + const labels = { + 'a4': 'A4 Hochformat', + 'a6p': 'A6 Hochformat', + 'a6l': 'A6 Querformat', + }; + return labels[format] || format; +} + +/** + * Formatiert Font für lesbare Ausgabe + */ +function formatFontLabel(font) { + const labels = { + 'tilda': 'Tilda', + 'alva': 'Alva', + 'ellie': 'Ellie', + }; + return labels[font] || font; +} + +/** + * Baut das komplette Webhook-Datenobjekt zusammen + * Enthält ALLE relevanten Felder für Bestellbestätigung und n8n Workflow + */ +function buildWebhookData(state, backendResult) { + const answers = state.answers || {}; + const order = state.order || {}; + const quote = state.quote || {}; + const ctx = state.ctx || {}; + + // Gutschein-Informationen + const voucherCode = order.voucherStatus?.valid ? order.voucherCode : null; + const voucherDiscount = order.voucherStatus?.valid ? (order.voucherStatus.discount || 0) : 0; + + // Umschlag-Format ermitteln + const envelopeFormat = answers.envelope ? getEnvelopeFormat(answers.format) : null; + + // Inland/Ausland zählen + let domesticCount = 0; + let internationalCount = 0; + const addressMode = state.addressMode || 'classic'; + const rows = addressMode === 'free' ? (state.freeAddressRows || []) : (state.recipientRows || []); + + for (const row of rows) { + if (!row) continue; + const country = addressMode === 'free' ? (row.line5 || '') : (row.country || ''); + const countryLower = country.toLowerCase().trim(); + const isDomestic = !countryLower || + countryLower === 'deutschland' || + countryLower === 'germany' || + countryLower === 'de'; + + if (isDomestic) { + domesticCount++; + } else { + internationalCount++; + } + } + + return { + // === BESTELLNUMMER & ZEITSTEMPEL === + orderNumber: backendResult?.orderNumber || null, + timestamp: new Date().toISOString(), + + // === KUNDE === + customerType: answers.customerType || 'private', + customerTypeLabel: answers.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde', + + // === PRODUKT === + product: ctx.product?.key || null, + productLabel: ctx.product?.label || null, + productCategory: ctx.product?.category || null, + + // === MENGE === + quantity: parseInt(answers.quantity) || 0, + domesticCount: domesticCount, + internationalCount: internationalCount, + + // === FORMAT & SCHRIFT === + format: answers.format || null, + formatLabel: formatFormatLabel(answers.format), + font: answers.font || 'tilda', + fontLabel: formatFontLabel(answers.font || 'tilda'), + + // === VERSAND === + shippingMode: answers.shippingMode || null, + shippingModeLabel: answers.shippingMode === 'direct' ? 'Einzelversand durch Skrift' : 'Sammellieferung', + + // === UMSCHLAG === + envelopeIncluded: answers.envelope === true, + envelopeFormat: envelopeFormat, + envelopeFormatLabel: envelopeFormat === 'DIN_LANG' ? 'DIN Lang' : (envelopeFormat === 'C6' ? 'C6' : null), + envelopeMode: answers.envelopeMode || null, + envelopeModeLabel: answers.envelopeMode === 'recipientData' ? 'Empfängeradresse' : + (answers.envelopeMode === 'customText' ? 'Individueller Text' : null), + envelopeFont: answers.envelopeFont || answers.font || 'tilda', + envelopeFontLabel: formatFontLabel(answers.envelopeFont || answers.font || 'tilda'), + envelopeCustomText: answers.envelopeCustomText || null, + + // === INHALT === + contentCreateMode: answers.contentCreateMode || null, + contentCreateModeLabel: answers.contentCreateMode === 'self' ? 'Selbst erstellt' : + (answers.contentCreateMode === 'textservice' ? 'Textservice' : null), + letterText: answers.letterText || null, + + // === MOTIV === + motifNeeded: answers.motifNeed === true, + motifSource: answers.motifSource || null, + motifSourceLabel: answers.motifSource === 'upload' ? 'Eigenes Motiv hochgeladen' : + (answers.motifSource === 'printed' ? 'Bedruckte Karten verwenden' : + (answers.motifSource === 'design' ? 'Designservice' : null)), + motifFileName: answers.motifFileName || null, + motifFileMeta: answers.motifFileMeta || null, + + // === SERVICES === + serviceText: answers.serviceText === true, + serviceDesign: answers.serviceDesign === true, + serviceApi: answers.serviceApi === true, + + // === FOLLOW-UP DETAILS (nur bei Follow-ups) === + followupYearlyVolume: ctx.product?.isFollowUp ? (answers.followupYearlyVolume || null) : null, + followupCreateMode: ctx.product?.isFollowUp ? (answers.followupCreateMode || null) : null, + followupCreateModeLabel: ctx.product?.isFollowUp ? ( + answers.followupCreateMode === 'auto' ? 'Automatisch (API)' : + (answers.followupCreateMode === 'manual' ? 'Manuell' : null) + ) : null, + followupSourceSystem: ctx.product?.isFollowUp ? (answers.followupSourceSystem || null) : null, + followupTriggerDescription: ctx.product?.isFollowUp ? (answers.followupTriggerDescription || null) : null, + followupCheckCycle: ctx.product?.isFollowUp ? (answers.followupCheckCycle || null) : null, + followupCheckCycleLabel: ctx.product?.isFollowUp ? ( + answers.followupCheckCycle === 'weekly' ? 'Wöchentlich' : + (answers.followupCheckCycle === 'monthly' ? 'Monatlich' : + (answers.followupCheckCycle === 'quarterly' ? 'Quartalsweise' : null)) + ) : null, + + // === GUTSCHEIN === + voucherCode: voucherCode, + voucherDiscount: voucherDiscount, + + // === PREISE === + currency: quote.currency || 'EUR', + subtotalNet: quote.subtotalNet || 0, + vatRate: quote.vatRate || 0.19, + vatAmount: quote.vatAmount || 0, + totalGross: quote.totalGross || 0, + priceLines: quote.lines || [], + + // === KUNDENDATEN (Rechnungsadresse) === + billingFirstName: order.billing?.firstName || '', + billingLastName: order.billing?.lastName || '', + billingCompany: order.billing?.company || '', + billingEmail: order.billing?.email || '', + billingPhone: order.billing?.phone || '', + billingStreet: order.billing?.street || '', + billingHouseNumber: order.billing?.houseNumber || '', + billingZip: order.billing?.zip || '', + billingCity: order.billing?.city || '', + billingCountry: order.billing?.country || 'Deutschland', + + // === LIEFERADRESSE (falls abweichend) === + shippingDifferent: order.shippingDifferent || false, + shippingFirstName: order.shippingDifferent ? (order.shipping?.firstName || '') : null, + shippingLastName: order.shippingDifferent ? (order.shipping?.lastName || '') : null, + shippingCompany: order.shippingDifferent ? (order.shipping?.company || '') : null, + shippingStreet: order.shippingDifferent ? (order.shipping?.street || '') : null, + shippingHouseNumber: order.shippingDifferent ? (order.shipping?.houseNumber || '') : null, + shippingZip: order.shippingDifferent ? (order.shipping?.zip || '') : null, + shippingCity: order.shippingDifferent ? (order.shipping?.city || '') : null, + shippingCountry: order.shippingDifferent ? (order.shipping?.country || 'Deutschland') : null, + + // === EMPFÄNGERLISTE === + addressMode: addressMode, + addressModeLabel: addressMode === 'free' ? 'Freie Adresszeilen' : 'Klassische Adresse', + recipients: addressMode === 'classic' ? (state.recipientRows || []).map((r, i) => ({ + index: i, + firstName: r?.firstName || '', + lastName: r?.lastName || '', + street: r?.street || '', + houseNumber: r?.houseNumber || '', + zip: r?.zip || '', + city: r?.city || '', + country: r?.country || 'Deutschland', + })) : null, + recipientsFree: addressMode === 'free' ? (state.freeAddressRows || []).map((r, i) => ({ + index: i, + line1: r?.line1 || '', + line2: r?.line2 || '', + line3: r?.line3 || '', + line4: r?.line4 || '', + line5: r?.line5 || '', + })) : null, + + // === PLATZHALTER === + placeholdersEnvelope: state.placeholders?.envelope || [], + placeholdersLetter: state.placeholders?.letter || [], + placeholderValues: state.placeholderValues || {}, + + // === BACKEND RESULT (falls vorhanden) === + backendPath: backendResult?.path || null, + backendFiles: backendResult?.files || [], + backendSummary: backendResult?.summary || null, + }; +} + +/** + * Bereitet Metadaten für Backend vor + */ +function prepareOrderMetadata(state) { + return { + customer: { + type: state.answers?.customerType || 'private', + firstName: state.order?.firstName || '', + lastName: state.order?.lastName || '', + company: state.order?.company || '', + email: state.order?.email || '', + phone: state.order?.phone || '', + street: state.order?.street || '', + zip: state.order?.zip || '', + city: state.order?.city || '', + }, + orderDate: new Date().toISOString(), + product: state.ctx?.product?.key || '', + quantity: state.answers?.quantity || 1, + format: state.answers?.format || 'A4', + shippingMode: state.answers?.shippingMode || 'direct', + quote: state.quote || {}, + voucherCode: state.order?.voucherCode || null, + }; +} + +/** + * Erweiterte Order-Submit Funktion mit Backend-Integration + */ +export async function handleOrderSubmitWithBackend(state) { + const isB2B = state.answers?.customerType === "business"; + const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; + const webhookUrl = backend.order_webhook_url; + const redirectUrlBusiness = backend.redirect_url_business; + const redirectUrlPrivate = backend.redirect_url_private; + const api = window.SkriftBackendAPI; + + // Prüfe ob Backend konfiguriert ist + if (!backend.api_url) { + console.warn('[Backend Integration] Backend API URL nicht konfiguriert'); + // Fallback zur alten Logik + return handleOrderSubmitLegacy(state); + } + + try { + // 1. Backend Health Check + const isHealthy = await api.healthCheck(); + if (!isHealthy) { + throw new Error('Backend ist nicht erreichbar'); + } + + // 2. Bestellnummer generieren (fortlaufend vom WP-Backend) + const orderNumber = await api.generateOrderNumber(); + + // 3. Daten vorbereiten + const letters = prepareLettersForBackend(state); + const envelopes = prepareEnvelopesForBackend(state); + const metadata = prepareOrderMetadata(state); + + console.log('[Backend Integration] Generating order:', { + orderNumber, + letters, + envelopes, + metadata, + }); + + // 4. Order im Backend generieren + const result = await api.generateOrder( + orderNumber, + letters, + envelopes, + metadata + ); + + if (!result.success) { + throw new Error(result.error || 'Order generation failed'); + } + + console.log('[Backend Integration] Order generated successfully:', result); + + // 5. Gutschein als verwendet markieren (falls vorhanden) + const voucherCode = state.order?.voucherStatus?.valid + ? state.order.voucherCode + : null; + if (voucherCode) { + try { + const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; + await fetch(restUrl + "skrift/v1/voucher/use", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: voucherCode }), + }); + } catch (error) { + console.warn('[Backend Integration] Fehler beim Markieren des Gutscheins:', error); + } + } + + // 6. Webhook aufrufen (wenn konfiguriert) + if (webhookUrl) { + try { + const webhookData = buildWebhookData(state, result); + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(webhookData), + }); + + if (!response.ok) { + console.warn('[Backend Integration] Webhook call failed:', response.statusText); + } + } catch (error) { + console.warn('[Backend Integration] Webhook error:', error); + } + } + + // 7. Weiterleitung + if (isB2B) { + if (redirectUrlBusiness) { + // Bestellnummer als Query-Parameter anhängen + const redirectUrl = new URL(redirectUrlBusiness); + redirectUrl.searchParams.set('orderNumber', result.orderNumber); + window.location.href = redirectUrl.toString(); + } else { + alert( + `Vielen Dank für Ihre Bestellung!\n\nBestellnummer: ${result.orderNumber}\n\nSie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details.` + ); + } + } else { + // Privatkunde: Zu PayPal weiterleiten + if (redirectUrlPrivate) { + const redirectUrl = new URL(redirectUrlPrivate); + redirectUrl.searchParams.set('orderNumber', result.orderNumber); + window.location.href = redirectUrl.toString(); + } else { + alert( + `Bestellung erfolgreich erstellt!\n\nBestellnummer: ${result.orderNumber}\n\nWeiterleitung zu PayPal folgt...` + ); + } + } + + } catch (error) { + console.error('[Backend Integration] Order submission failed:', error); + + alert( + `Fehler bei der Bestellverarbeitung:\n\n${error.message}\n\nBitte versuchen Sie es erneut oder kontaktieren Sie uns.` + ); + } +} + +/** + * Legacy Order-Submit (Fallback ohne Backend) + */ +async function handleOrderSubmitLegacy(state) { + const isB2B = state.answers?.customerType === "business"; + const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; + const webhookUrl = backend.order_webhook_url; + const redirectUrlBusiness = backend.redirect_url_business; + const redirectUrlPrivate = backend.redirect_url_private; + + // Gutschein als verwendet markieren + const voucherCode = state.order?.voucherStatus?.valid + ? state.order.voucherCode + : null; + if (voucherCode) { + try { + const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; + await fetch(restUrl + "skrift/v1/voucher/use", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: voucherCode }), + }); + } catch (error) { + console.warn('Fehler beim Markieren des Gutscheins:', error); + } + } + + // Webhook aufrufen + if (isB2B && webhookUrl) { + try { + const webhookData = buildWebhookData(state, null); + + await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(webhookData), + }); + } catch (error) { + console.warn('Webhook error:', error); + } + } + + // Weiterleitung + if (isB2B && redirectUrlBusiness) { + window.location.href = redirectUrlBusiness; + } else if (!isB2B && redirectUrlPrivate) { + window.location.href = redirectUrlPrivate; + } else { + alert("Vielen Dank für Ihre Bestellung!"); + } +} + +export default { + handleOrderSubmitWithBackend, + prepareLettersForBackend, + prepareEnvelopesForBackend, + mapFontToBackend, + mapFormatToBackend, + buildWebhookData, +}; diff --git a/skrift-configurator/assets/js/configurator-preview-manager.js b/skrift-configurator/assets/js/configurator-preview-manager.js new file mode 100644 index 0000000..d2c5405 --- /dev/null +++ b/skrift-configurator/assets/js/configurator-preview-manager.js @@ -0,0 +1,622 @@ +/** + * Preview Manager - Verwaltet Preview-Generation mit Batch-Loading, Navigation und Rate-Limiting + */ + +import { preparePlaceholdersForIndex } from './configurator-utils.js'; + +class PreviewManager { + constructor(api) { + this.api = api; + this.currentBatchIndex = 0; + this.currentDocIndex = 0; + this.previewCount = 0; + this.batchSize = 25; + this.currentBatchPreviews = []; + this.requestsRemaining = 10; + this.maxRequests = 10; + // Für Änderungserkennung und Validierungs-Caching + this.lastValidatedTextHash = null; + this.lastValidationResult = null; + this.lastOverflowFiles = null; // null = keine Validierung durchgeführt + } + + /** + * Erzeugt einen einfachen Hash für Text-Vergleich + */ + hashText(text, quantity, format, font) { + const str = `${text || ''}|${quantity || 1}|${format || 'a4'}|${font || 'tilda'}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return hash.toString(36); + } + + /** + * Prüft ob sich der Text seit der letzten Validierung geändert hat + */ + hasTextChanged(state) { + const currentHash = this.hashText( + state.answers?.letterText, + state.answers?.quantity, + state.answers?.format, + state.answers?.font + ); + return currentHash !== this.lastValidatedTextHash; + } + + /** + * Speichert den aktuellen Text-Hash nach Validierung + */ + saveTextHash(state) { + this.lastValidatedTextHash = this.hashText( + state.answers?.letterText, + state.answers?.quantity, + state.answers?.format, + state.answers?.font + ); + } + + /** + * Bereitet Platzhalter für ein bestimmtes Dokument vor + * Verwendet jetzt die gemeinsame Utility-Funktion + */ + preparePlaceholders(state, index) { + return preparePlaceholdersForIndex(state, index); + } + + /** + * Generiert ein Letter-Objekt für ein bestimmtes Dokument + */ + prepareLetter(state, index, isEnvelope = false) { + const placeholders = this.preparePlaceholders(state, index); + + if (isEnvelope) { + const isRecipientMode = state.answers?.envelopeMode === 'recipientData'; + const isCustomMode = state.answers?.envelopeMode === 'customText'; + const addressMode = state.addressMode || 'classic'; + + let envelopeText = ''; + let envelopeType = 'recipient'; + + if (isRecipientMode) { + if (addressMode === 'free' && Array.isArray(state.freeAddressRows) && state.freeAddressRows.length > index) { + // Freie Adresse: Bis zu 5 Zeilen + const freeAddr = state.freeAddressRows[index]; + const lines = []; + if (freeAddr.line1) lines.push(freeAddr.line1); + if (freeAddr.line2) lines.push(freeAddr.line2); + if (freeAddr.line3) lines.push(freeAddr.line3); + if (freeAddr.line4) lines.push(freeAddr.line4); + if (freeAddr.line5) lines.push(freeAddr.line5); + envelopeText = lines.join('\n'); + envelopeType = 'free'; + } else if (addressMode === 'classic' && Array.isArray(state.recipientRows) && state.recipientRows.length > index) { + // Klassische Adresse + const recipient = state.recipientRows[index]; + const lines = []; + + const fullName = `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim(); + if (fullName) lines.push(fullName); + + const streetLine = `${recipient.street || ''} ${recipient.houseNumber || ''}`.trim(); + if (streetLine) lines.push(streetLine); + + const location = `${recipient.zip || ''} ${recipient.city || ''}`.trim(); + if (location) lines.push(location); + + if (recipient.country && recipient.country !== 'Deutschland') { + lines.push(recipient.country); + } + + envelopeText = lines.join('\n'); + envelopeType = 'recipient'; + } + } else if (isCustomMode) { + envelopeText = state.answers?.envelopeCustomText || ''; + envelopeType = 'custom'; + } + + return { + index: index, + text: envelopeText, + font: state.answers?.envelopeFont || 'tilda', + format: state.answers?.format === 'a4' ? 'DIN_LANG' : 'C6', + placeholders: placeholders, + type: 'envelope', + envelopeType: envelopeType, + envelope: { + type: envelopeType + } + }; + } else { + return { + index: index, + text: state.answers?.letterText || state.answers?.text || state.answers?.briefText || '', + font: state.answers?.font || 'tilda', + format: state.answers?.format || 'a4', + placeholders: placeholders, + type: 'letter' + }; + } + } + + /** + * Lädt ALLE Previews auf einmal + * @param {boolean} skipLimitCheck - Wenn true, wird das Request-Limit ignoriert (für Validierung) + */ + async loadAllPreviews(state, isEnvelope = false, skipLimitCheck = false) { + // Request-Limit nur prüfen wenn nicht übersprungen (normale Preview-Generierung) + if (!skipLimitCheck) { + if (this.requestsRemaining <= 0) { + throw new Error(`Maximale Anzahl von ${this.maxRequests} Vorschau-Anfragen erreicht.`); + } + this.requestsRemaining--; + } + + const letters = []; + for (let i = 0; i < this.previewCount; i++) { + letters.push(this.prepareLetter(state, i, isEnvelope)); + } + + try { + const result = await this.api.generatePreviewBatch(letters); + + if (!result.success) { + throw new Error(result.error || 'Preview generation failed'); + } + + this.currentBatchPreviews = result.previews; + this.lastValidationResult = result; // Speichere für Overflow-Check + return true; + } catch (error) { + console.error('[PreviewManager] Load error:', error); + throw error; + } + } + + /** + * Validiert Textlänge durch Preview-Generierung + * Wenn Text nicht geändert wurde und Preview bereits existiert, wird gecachtes Ergebnis verwendet + * @returns {Object} { valid: boolean, overflowFiles: Array, fromCache: boolean } + */ + async validateTextLength(state, forceRevalidate = false) { + // Prüfe ob wir gecachtes Ergebnis verwenden können + if (!forceRevalidate && !this.hasTextChanged(state) && this.lastOverflowFiles !== null) { + console.log('[PreviewManager] Using cached validation result'); + return { + valid: this.lastOverflowFiles.length === 0, + overflowFiles: this.lastOverflowFiles, + fromCache: true + }; + } + + this.previewCount = parseInt(state.answers?.quantity) || 1; + + try { + // skipLimitCheck = true: Validierung soll immer möglich sein, auch ohne verbleibende Anfragen + await this.loadAllPreviews(state, false, true); + + const result = this.lastValidationResult; + if (!result) { + this.lastOverflowFiles = []; + this.saveTextHash(state); + return { valid: true, overflowFiles: [], fromCache: false }; + } + + const hasOverflow = result.hasOverflow || false; + const overflowFiles = (result.overflowFiles || []).map(f => ({ + index: f.index, + lineCount: f.lineCount, + lineLimit: f.lineLimit + })); + + // Cache das Ergebnis + this.lastOverflowFiles = overflowFiles; + this.saveTextHash(state); + + return { + valid: !hasOverflow, + overflowFiles: overflowFiles, + fromCache: false + }; + } catch (error) { + console.error('[PreviewManager] Validation error:', error); + + // Bei Fehlern: Nicht durchlassen - Nutzer muss es erneut versuchen + return { + valid: false, + overflowFiles: [], + error: error.message, + fromCache: false + }; + } + } + + /** + * Gibt gecachte Overflow-Infos zurück (ohne neue Anfrage) + */ + getCachedOverflowFiles() { + return this.lastOverflowFiles || []; + } + + /** + * Prüft ob Validierung bereits erfolgt ist (für UI-Anzeige) + */ + hasValidationResult() { + return this.lastOverflowFiles !== null; + } + + /** + * Generiert Previews und zeigt Overflow-Warnung wenn nötig + * @returns {Object} { success: boolean, hasOverflow: boolean, overflowFiles: Array } + */ + async generatePreviews(state, dom, isEnvelope = false) { + const btn = dom.querySelector('.sk-preview-generate-btn'); + const statusEl = dom.querySelector('.sk-preview-status'); + const requestCounterEl = dom.querySelector('.sk-preview-request-counter'); + + if (!btn) return { success: false, hasOverflow: false, overflowFiles: [] }; + + try { + btn.disabled = true; + btn.textContent = 'Generiere Vorschau...'; + + this.previewCount = parseInt(state.answers?.quantity) || 1; + + if (statusEl) { + statusEl.textContent = `Lade alle ${this.previewCount} Dokumente...`; + } + + await this.loadAllPreviews(state, isEnvelope); + + // Text-Hash speichern für Änderungserkennung + if (!isEnvelope) { + this.saveTextHash(state); + } + + this.currentDocIndex = 0; + this.showPreview(0, dom); + this.showNavigationControls(dom, state, isEnvelope); + + if (statusEl) { + statusEl.textContent = `Dokument 1 von ${this.previewCount}`; + } + + if (requestCounterEl) { + requestCounterEl.textContent = `Verbleibende Vorschau-Anfragen: ${this.requestsRemaining}/${this.maxRequests}`; + } + + btn.disabled = false; + btn.textContent = btn.textContent.includes('Umschlag') ? 'Umschlag Vorschau generieren' : 'Vorschau Schriftstück generieren'; + + // Overflow-Prüfung für Briefe (nicht Umschläge) + if (!isEnvelope && this.lastValidationResult) { + const hasOverflow = this.lastValidationResult.hasOverflow || false; + const overflowFiles = (this.lastValidationResult.overflowFiles || []).map(f => ({ + index: f.index, + lineCount: f.lineCount, + lineLimit: f.lineLimit + })); + + // Cache speichern + this.lastOverflowFiles = overflowFiles; + + return { success: true, hasOverflow, overflowFiles }; + } + + return { success: true, hasOverflow: false, overflowFiles: [] }; + + } catch (error) { + console.error('[PreviewManager] Error:', error); + btn.disabled = false; + btn.textContent = isEnvelope ? 'Umschlag Vorschau generieren' : 'Vorschau generieren'; + + // Fehlermeldung im Preview-Bereich anzeigen + const previewBox = dom.querySelector('.sk-preview-box'); + if (previewBox) { + previewBox.innerHTML = ''; + const notice = document.createElement('div'); + notice.style.cssText = 'padding: 20px; text-align: center; color: #666;'; + notice.innerHTML = ` +

Die Vorschau konnte nicht generiert werden.

+

Bitte versuchen Sie es erneut oder kontaktieren Sie uns bei anhaltenden Problemen.

+ `; + previewBox.appendChild(notice); + } + + return { success: false, hasOverflow: false, overflowFiles: [], error: error.message }; + } + } + + /** + * Zeigt eine Preview an einem bestimmten Index + */ + showPreview(index, dom) { + const preview = this.currentBatchPreviews[index]; + if (!preview) { + console.warn('[PreviewManager] Preview not loaded:', index); + return; + } + + const previewBox = dom.querySelector('.sk-preview-box'); + const statusEl = dom.querySelector('.sk-preview-status'); + + if (!previewBox) { + console.warn('[PreviewManager] Preview box not found'); + return; + } + + const imgContainer = document.createElement('div'); + imgContainer.style.cssText = 'position: relative; overflow: hidden; margin-top: 15px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;'; + + const img = document.createElement('img'); + img.src = `${this.api.baseURL}${preview.url}?t=${Date.now()}`; + img.style.cssText = 'width: 100%; display: block;'; + + imgContainer.addEventListener('click', () => { + this.showFullscreenPreview(img.src); + }); + + imgContainer.addEventListener('mouseenter', () => { + imgContainer.style.opacity = '0.9'; + }); + + imgContainer.addEventListener('mouseleave', () => { + imgContainer.style.opacity = '1'; + }); + + imgContainer.appendChild(img); + previewBox.innerHTML = ''; + previewBox.appendChild(imgContainer); + + if (statusEl) { + statusEl.textContent = `Dokument ${index + 1} von ${this.previewCount}`; + } + + this.currentDocIndex = index; + } + + /** + * Navigiert zur nächsten/vorherigen Preview + */ + navigateWithinBatch(direction, dom) { + const newIndex = this.currentDocIndex + direction; + + if (newIndex < 0 || newIndex >= this.currentBatchPreviews.length) { + return; + } + + this.showPreview(newIndex, dom); + this.updateNavigationButtons(); + } + + // navigateToBatch wurde entfernt - Batch-Loading wird nicht mehr verwendet + // Alle Previews werden jetzt auf einmal geladen (loadAllPreviews) + + /** + * Ermittelt den aktuellen lokalen Index + */ + getCurrentLocalIndex(dom) { + const statusEl = dom.querySelector('.sk-preview-status'); + if (!statusEl) return 0; + + const match = statusEl.textContent.match(/Dokument (\d+) von/); + if (match) { + return parseInt(match[1]) - 1; + } + return 0; + } + + /** + * Aktualisiert den Request Counter im DOM + */ + updateRequestCounter(dom) { + const requestCounterEl = dom.querySelector('.sk-preview-request-counter'); + if (requestCounterEl) { + requestCounterEl.textContent = `Verbleibende Vorschau-Anfragen: ${this.requestsRemaining}/${this.maxRequests}`; + } + } + + /** + * Aktualisiert Button-States der Navigation + */ + updateNavigationButtons() { + const navWrapper = document.querySelector('.sk-preview-navigation-container'); + if (!navWrapper) return; + + const navContainer = navWrapper.querySelector('.sk-preview-navigation'); + if (!navContainer) return; + + const prevDocBtn = navContainer.querySelector('.sk-preview-prev-doc'); + const nextDocBtn = navContainer.querySelector('.sk-preview-next-doc'); + + if (prevDocBtn) { + const canPrev = this.currentDocIndex > 0; + prevDocBtn.disabled = !canPrev; + prevDocBtn.style.opacity = canPrev ? '1' : '0.5'; + prevDocBtn.style.cursor = canPrev ? 'pointer' : 'not-allowed'; + } + + if (nextDocBtn) { + const canNext = this.currentDocIndex < this.currentBatchPreviews.length - 1; + nextDocBtn.disabled = !canNext; + nextDocBtn.style.opacity = canNext ? '1' : '0.5'; + nextDocBtn.style.cursor = canNext ? 'pointer' : 'not-allowed'; + } + } + + /** + * Zeigt Navigation Controls + */ + showNavigationControls(dom, state, isEnvelope) { + const navWrapper = dom.querySelector('.sk-preview-navigation-container'); + if (!navWrapper) return; + + let navContainer = navWrapper.querySelector('.sk-preview-navigation'); + if (navContainer) { + navContainer.remove(); + } + + navContainer = document.createElement('div'); + navContainer.className = 'sk-preview-navigation'; + navContainer.style.display = 'flex'; + navContainer.style.gap = '10px'; + navContainer.style.alignItems = 'center'; + + const buttonStyle = { + padding: '8px 12px', + fontSize: '20px', + lineHeight: '1', + minWidth: '40px', + backgroundColor: '#fff', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer' + }; + + const prevDocBtn = document.createElement('button'); + prevDocBtn.type = 'button'; + prevDocBtn.className = 'sk-btn sk-btn-secondary sk-preview-prev-doc'; + prevDocBtn.textContent = '‹'; + prevDocBtn.title = 'Vorheriges Dokument'; + Object.assign(prevDocBtn.style, buttonStyle); + prevDocBtn.onclick = () => { + this.navigateWithinBatch(-1, dom); + }; + + const nextDocBtn = document.createElement('button'); + nextDocBtn.type = 'button'; + nextDocBtn.className = 'sk-btn sk-btn-secondary sk-preview-next-doc'; + nextDocBtn.textContent = '›'; + nextDocBtn.title = 'Nächstes Dokument'; + Object.assign(nextDocBtn.style, buttonStyle); + nextDocBtn.onclick = () => { + this.navigateWithinBatch(1, dom); + }; + + navContainer.appendChild(prevDocBtn); + navContainer.appendChild(nextDocBtn); + + navWrapper.appendChild(navContainer); + this.updateNavigationButtons(); + } + + /** + * Keyboard Event Handler + */ + handleKeyboardNavigation(event, state, dom, isEnvelope = false) { + if (this.currentBatchPreviews.length === 0) return; + + const currentLocalIndex = this.getCurrentLocalIndex(dom); + + if (event.key === 'ArrowLeft' && currentLocalIndex > 0) { + event.preventDefault(); + this.navigateWithinBatch(-1, dom); + } else if (event.key === 'ArrowRight' && currentLocalIndex < this.currentBatchPreviews.length - 1) { + event.preventDefault(); + this.navigateWithinBatch(1, dom); + } + } + + /** + * Cleanup + */ + destroy() { + this.currentBatchPreviews = []; + this.currentBatchIndex = 0; + this.previewCount = 0; + } + + /** + * Reset für neue Session + */ + reset() { + this.currentBatchPreviews = []; + this.currentBatchIndex = 0; + this.previewCount = 0; + this.requestsRemaining = this.maxRequests; + } + + /** + * Zeigt Fullscreen-Vorschau als Modal + */ + showFullscreenPreview(imgSrc) { + const existingModal = document.getElementById('sk-preview-fullscreen-modal'); + if (existingModal) { + existingModal.remove(); + } + + const modal = document.createElement('div'); + modal.id = 'sk-preview-fullscreen-modal'; + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + cursor: pointer; + `; + + const modalImg = document.createElement('img'); + modalImg.src = imgSrc; + modalImg.style.cssText = ` + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + `; + + const closeBtn = document.createElement('button'); + closeBtn.innerHTML = '×'; + closeBtn.style.cssText = ` + position: absolute; + top: 20px; + right: 30px; + font-size: 40px; + color: white; + background: none; + border: none; + cursor: pointer; + line-height: 1; + `; + + const hint = document.createElement('div'); + hint.textContent = 'Klicken zum Schließen'; + hint.style.cssText = ` + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + `; + + modal.appendChild(modalImg); + modal.appendChild(closeBtn); + modal.appendChild(hint); + + const closeModal = () => modal.remove(); + modal.addEventListener('click', closeModal); + closeBtn.addEventListener('click', closeModal); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeModal(); + }, { once: true }); + + document.body.appendChild(modal); + } +} + +// Globale Instanzen +window.envelopePreviewManager = null; +window.contentPreviewManager = null; + +export default PreviewManager; diff --git a/skrift-configurator/assets/js/configurator-pricing.js b/skrift-configurator/assets/js/configurator-pricing.js new file mode 100644 index 0000000..80303bf --- /dev/null +++ b/skrift-configurator/assets/js/configurator-pricing.js @@ -0,0 +1,1035 @@ +/** + * Pricing Calculator für Skrift Konfigurator + * Berechnet Preise basierend auf Kundentyp (B2B/B2C) und Produktkonfiguration + */ + +/** + * Sichere mathematische Formel-Auswertung ohne eval() + * Unterstützt: +, -, *, /, Klammern, Zahlen, Dezimalzahlen, + * Vergleichsoperatoren (>=, <=, >, <, ==, !=), ternärer Operator (?:), + * und Math-Funktionen (sqrt, abs, min, max, pow, floor, ceil, round) + */ +function safeEvaluateMathFormula(formula) { + // Erlaubte Math-Funktionen (Whitelist) + const mathFunctions = { + 'sqrt': Math.sqrt, + 'abs': Math.abs, + 'min': Math.min, + 'max': Math.max, + 'pow': Math.pow, + 'floor': Math.floor, + 'ceil': Math.ceil, + 'round': Math.round, + }; + + // Tokenizer: Zerlegt Formel in Tokens + function tokenize(str) { + const tokens = []; + let i = 0; + str = str.replace(/\s+/g, ''); // Whitespace entfernen + + while (i < str.length) { + const char = str[i]; + + // Math.xxx Funktionen + if (str.substring(i, i + 5) === 'Math.') { + i += 5; + let funcName = ''; + while (i < str.length && /[a-zA-Z]/.test(str[i])) { + funcName += str[i]; + i++; + } + if (mathFunctions[funcName]) { + tokens.push({ type: 'function', name: funcName }); + } else { + throw new Error(`Unbekannte Math-Funktion: Math.${funcName}`); + } + } + // Zahl (inkl. Dezimalzahlen) + else if (/[0-9.]/.test(char) || (char === '-' && (tokens.length === 0 || ['(', '+', '-', '*', '/', '>=', '<=', '>', '<', '==', '!=', '?', ':'].includes(tokens[tokens.length - 1]?.type || tokens[tokens.length - 1])))) { + let numStr = ''; + if (char === '-') { + numStr = '-'; + i++; + } + while (i < str.length && /[0-9.]/.test(str[i])) { + numStr += str[i]; + i++; + } + const num = parseFloat(numStr); + if (isNaN(num)) { + throw new Error(`Ungültige Zahl: ${numStr}`); + } + tokens.push({ type: 'number', value: num }); + } + // Zwei-Zeichen-Operatoren + else if (str.substring(i, i + 2) === '>=') { + tokens.push({ type: '>=' }); + i += 2; + } + else if (str.substring(i, i + 2) === '<=') { + tokens.push({ type: '<=' }); + i += 2; + } + else if (str.substring(i, i + 2) === '==') { + tokens.push({ type: '==' }); + i += 2; + } + else if (str.substring(i, i + 2) === '!=') { + tokens.push({ type: '!=' }); + i += 2; + } + // Ein-Zeichen-Operatoren und Klammern + else if (['+', '-', '*', '/', '(', ')', '>', '<', '?', ':', ','].includes(char)) { + tokens.push({ type: char }); + i++; + } + // Ungültiges Zeichen + else { + throw new Error(`Ungültiges Zeichen in Formel: ${char}`); + } + } + + return tokens; + } + + // Rekursiver Parser mit Operator-Präzedenz + function parse(tokens) { + let pos = 0; + + function peek() { + return tokens[pos]; + } + + function consume(expectedType) { + const token = tokens[pos]; + if (expectedType && token?.type !== expectedType) { + throw new Error(`Erwartet ${expectedType}, gefunden ${token?.type}`); + } + pos++; + return token; + } + + // Ternärer Operator (niedrigste Präzedenz) + function parseTernary() { + let condition = parseComparison(); + + if (peek()?.type === '?') { + consume('?'); + const trueValue = parseTernary(); + consume(':'); + const falseValue = parseTernary(); + return condition ? trueValue : falseValue; + } + + return condition; + } + + // Vergleichsoperatoren + function parseComparison() { + let left = parseAddSub(); + + while (peek()?.type && ['>=', '<=', '>', '<', '==', '!='].includes(peek().type)) { + const op = consume().type; + const right = parseAddSub(); + switch (op) { + case '>=': left = left >= right ? 1 : 0; break; + case '<=': left = left <= right ? 1 : 0; break; + case '>': left = left > right ? 1 : 0; break; + case '<': left = left < right ? 1 : 0; break; + case '==': left = left === right ? 1 : 0; break; + case '!=': left = left !== right ? 1 : 0; break; + } + } + + return left; + } + + // Addition und Subtraktion + function parseAddSub() { + let left = parseMulDiv(); + + while (peek()?.type && ['+', '-'].includes(peek().type)) { + const op = consume().type; + const right = parseMulDiv(); + left = op === '+' ? left + right : left - right; + } + + return left; + } + + // Multiplikation und Division + function parseMulDiv() { + let left = parseUnary(); + + while (peek()?.type && ['*', '/'].includes(peek().type)) { + const op = consume().type; + const right = parseUnary(); + if (op === '/') { + if (right === 0) throw new Error('Division durch Null'); + left = left / right; + } else { + left = left * right; + } + } + + return left; + } + + // Unäre Operatoren (negatives Vorzeichen) + function parseUnary() { + if (peek()?.type === '-') { + consume('-'); + return -parseUnary(); + } + return parsePrimary(); + } + + // Primäre Ausdrücke (Zahlen, Klammern, Funktionen) + function parsePrimary() { + const token = peek(); + + if (!token) { + throw new Error('Unerwartetes Ende der Formel'); + } + + // Zahl + if (token.type === 'number') { + consume(); + return token.value; + } + + // Funktion + if (token.type === 'function') { + consume(); + consume('('); + + // Argumente sammeln + const args = []; + if (peek()?.type !== ')') { + args.push(parseTernary()); + while (peek()?.type === ',') { + consume(','); + args.push(parseTernary()); + } + } + + consume(')'); + + const func = mathFunctions[token.name]; + return func(...args); + } + + // Geklammerter Ausdruck + if (token.type === '(') { + consume('('); + const value = parseTernary(); + consume(')'); + return value; + } + + throw new Error(`Unerwartetes Token: ${token.type}`); + } + + const result = parseTernary(); + + if (pos < tokens.length) { + throw new Error(`Unerwartete Tokens am Ende: ${tokens.slice(pos).map(t => t.type).join(', ')}`); + } + + return result; + } + + const tokens = tokenize(formula); + return parse(tokens); +} + +/** + * Holt Preise aus Backend-Settings + */ +function getPrices() { + return window.SkriftConfigurator?.settings?.prices || {}; +} + +/** + * Holt dynamische Pricing-Einstellungen aus Backend + */ +function getDynamicPricing() { + return window.SkriftConfigurator?.settings?.dynamic_pricing || {}; +} + +/** + * Prüft ob es sich um Follow-ups handelt + */ +function isFollowups(state) { + return state.ctx?.product?.key === "follow-ups"; +} + +/** + * Prüft ob Kunde Business-Kunde ist + */ +function isBusinessCustomer(state) { + return state.answers?.customerType === "business"; +} + +/** + * Prüft ob ein Land Deutschland ist (DE oder Deutschland) + * Case-insensitive Prüfung + */ +function isGermany(country) { + if (!country || country.trim() === "") return true; // Leeres Land = Deutschland (Standardannahme) + const normalized = country.trim().toLowerCase(); + return normalized === "de" || normalized === "deutschland" || normalized === "germany" || normalized === "ger" || normalized === "deu"; +} + +/** + * Zählt Inland- und Ausland-Empfänger + * Berücksichtigt sowohl klassische Adressen (recipientRows) als auch freie Adressen (freeAddressRows) + */ +export function countRecipientsByCountry(state) { + const quantity = state.answers?.quantity || 0; + const addressMode = state.addressMode || 'classic'; + + let domesticCount = 0; + let internationalCount = 0; + + if (addressMode === 'classic') { + const rows = Array.isArray(state.recipientRows) ? state.recipientRows : []; + for (let i = 0; i < quantity; i++) { + const country = rows[i]?.country || ""; + if (isGermany(country)) { + domesticCount++; + } else { + internationalCount++; + } + } + } else { + // Freie Adresse: line5 ist das Land + const rows = Array.isArray(state.freeAddressRows) ? state.freeAddressRows : []; + for (let i = 0; i < quantity; i++) { + const country = rows[i]?.line5 || ""; + if (isGermany(country)) { + domesticCount++; + } else { + internationalCount++; + } + } + } + + return { domesticCount, internationalCount }; +} + +/** + * Berechnet die Versandpreise pro Stück für Inland und Ausland + * Formel: Porto + Serviceaufschlag + Kuvert + Aufschlag Beschriftung + */ +export function calculateDirectShippingPrices(state) { + const prices = getPrices(); + + // Basis-Komponenten + const portoDomestic = prices.shipping_domestic || 0.95; + const portoInternational = prices.shipping_international || 1.25; + const serviceCharge = prices.shipping_service || 0.95; + const envelopeBase = prices.envelope_base || 0.50; + const labelingCharge = prices.envelope_labeling || 0.50; + + // Versand Inland = Porto DE + Serviceaufschlag + Kuvert + Aufschlag Beschriftung + const domesticPrice = portoDomestic + serviceCharge + envelopeBase + labelingCharge; + + // Versand Ausland = Porto Ausland + Serviceaufschlag + Kuvert + Aufschlag Beschriftung + const internationalPrice = portoInternational + serviceCharge + envelopeBase + labelingCharge; + + return { + domesticPrice, + internationalPrice, + portoDomestic, + portoInternational, + serviceCharge, + envelopeBase, + labelingCharge, + }; +} + +/** + * Berechnet Multiplikator basierend auf monatlichem Volumen (nur Follow-ups) + */ +function getFollowupMultiplier(monthlyVolume) { + const prices = getPrices(); + + if (monthlyVolume >= 1000) return prices.followup_mult_1000_plus || 1.0; + if (monthlyVolume >= 500) return prices.followup_mult_500_999 || 1.2; + if (monthlyVolume >= 200) return prices.followup_mult_200_499 || 1.4; + if (monthlyVolume >= 50) return prices.followup_mult_50_199 || 1.7; + if (monthlyVolume >= 5) return prices.followup_mult_5_49 || 2.0; + + return 2.0; // Default für < 5 +} + +/** + * Berechnet dynamischen Preis-Multiplikator basierend auf Menge + * (außer für Follow-ups) + */ +function getDynamicPriceMultiplier(state) { + // Follow-ups verwenden eigene Logik + if (isFollowups(state)) { + return 1.0; + } + + const quantity = state.answers.quantity || 0; + const isB2B = isBusinessCustomer(state); + const dynamicPricing = getDynamicPricing(); + + // Welche Formel verwenden? + const formula = isB2B + ? dynamicPricing.business_formula + : dynamicPricing.private_formula; + + // Welche Parameter verwenden? + const normQty = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + const minQty = isB2B + ? (dynamicPricing.business_min_quantity || 50) + : (dynamicPricing.private_min_quantity || 10); + + // Keine Formel definiert? Standard-Multiplikator 1 + if (!formula || formula.trim() === '') { + return 1.0; + } + + try { + // Platzhalter ersetzen + let evaluableFormula = formula + .replace(/%qty%/g, String(quantity)) + .replace(/%norm_b%/g, String(dynamicPricing.business_normal_quantity || 200)) + .replace(/%mind_b%/g, String(dynamicPricing.business_min_quantity || 50)) + .replace(/%norm_p%/g, String(dynamicPricing.private_normal_quantity || 50)) + .replace(/%mind_p%/g, String(dynamicPricing.private_min_quantity || 10)); + + // Sichere Formel-Auswertung ohne eval() + const multiplier = safeEvaluateMathFormula(evaluableFormula); + + // Sicherheitsprüfung + if (typeof multiplier !== 'number' || isNaN(multiplier) || !isFinite(multiplier) || multiplier < 0) { + console.warn('Ungültiger Multiplikator aus Formel:', multiplier); + return 1.0; + } + + return multiplier; + } catch (error) { + console.error('Fehler beim Auswerten der dynamischen Preisformel:', error); + return 1.0; + } +} + +/** + * Berechnet Preis pro Schriftstück + * Bei Direktversand wird der Durchschnittspreis basierend auf Inland/Ausland-Verteilung berechnet + */ +export function calculatePricePerPiece(state) { + const prices = getPrices(); + const product = state.ctx?.product; + + if (!product) return 0; + + let basePrice = product.basePrice || 0; + + // A4 Aufpreis (nur für Einladungen und Follow-ups, NICHT für Postkarten) + const needsA4Surcharge = + (product.key === "einladungen" || product.key === "follow-ups") && + state.answers.format === "a4"; + + if (needsA4Surcharge) { + basePrice += prices.a4_upgrade_surcharge || 0; + } + + // Multiplikator NUR auf Basispreis anwenden (nicht auf Versand/Umschlag) + let pricePerPiece = basePrice; + if (isFollowups(state) && state.answers.followupYearlyVolume) { + // Follow-ups: Spezielle Mengenstaffel + const monthlyVolume = parseInt(state.answers.followupYearlyVolume) || 0; + const multiplier = getFollowupMultiplier(monthlyVolume); + pricePerPiece *= multiplier; + } else { + // Alle anderen Produkte: Dynamische Preisberechnung + const dynamicMultiplier = getDynamicPriceMultiplier(state); + pricePerPiece *= dynamicMultiplier; + } + + // Versandkosten bei Direktversand - immer Inlandspreis für "ab"-Preis + if (state.answers.shippingMode === "direct") { + const shippingPrices = calculateDirectShippingPrices(state); + // Immer Inlandspreis verwenden für den "ab"-Preis + pricePerPiece += shippingPrices.domesticPrice; + } + + // Umschlag-Kosten (nur bei Bulk-Versand) + if ( + state.answers.shippingMode === "bulk" && + state.answers.envelope === true + ) { + // Grundpreis für Kuvert + pricePerPiece += prices.envelope_base || 0; + + // Zusätzliche Beschriftungskosten wenn Umschlag beschrieben wird + const labelingPrice = prices.envelope_labeling || prices.envelope_recipient_address || 0; + if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { + pricePerPiece += labelingPrice; + } + // Bei "none" nur Grundpreis + } + + // Motiv-Kosten sind Einmalkosten und werden NICHT auf Preis pro Stück gerechnet + // (werden separat in calculateSetupCosts berechnet) + + // Kaufmännische Rundung auf 2 Dezimalstellen + return Math.round(pricePerPiece * 100) / 100; +} + +/** + * Berechnet Grundpreis pro Stück für Follow-ups OHNE Multiplikator + * (für die Anzeige in der Kopfzeile) + */ +export function calculateFollowupBasePricePerPiece(state) { + const prices = getPrices(); + const product = state.ctx?.product; + + if (!product) return 0; + + let pricePerPiece = product.basePrice || 0; + + // A4 Aufpreis (nur für Einladungen und Follow-ups, NICHT für Postkarten) + const needsA4Surcharge = + (product.key === "einladungen" || product.key === "follow-ups") && + state.answers.format === "a4"; + + if (needsA4Surcharge) { + pricePerPiece += prices.a4_upgrade_surcharge || 0; + } + + // Versandkosten für Follow-ups (immer Direktversand, Inland-Preis als Basis) + const shippingPrices = calculateDirectShippingPrices(state); + pricePerPiece += shippingPrices.domesticPrice; + + // KEINE Einmalkosten (API-Anbindung, Motiv-Upload, etc.) - nur wiederkehrende Kosten pro Stück + // KEIN Multiplikator hier - das ist der Grundpreis + + return pricePerPiece; +} + +/** + * Berechnet Versandkosten + * Rückgabe: { total, shipping0Pct, shipping19Pct, domesticCount, internationalCount, shippingPrices } + */ +export function calculateShippingCosts(state) { + const prices = getPrices(); + const quantity = state.answers.quantity || 1; + + const result = { + total: 0, + shipping0Pct: 0, // Portoanteil mit 0% MwSt + shipping19Pct: 0, // Service + Kuvert + Beschriftung mit 19% MwSt + domesticCount: 0, + internationalCount: 0, + shippingPrices: null, + }; + + if (state.answers.shippingMode === "direct") { + // Direktversand: Preis abhängig vom Land + // Komponenten: Porto (0% MwSt) + Serviceaufschlag + Kuvert + Beschriftung (alle 19% MwSt) + const shippingPrices = calculateDirectShippingPrices(state); + result.shippingPrices = shippingPrices; + + // Empfänger nach Land zählen + const { domesticCount, internationalCount } = countRecipientsByCountry(state); + result.domesticCount = domesticCount; + result.internationalCount = internationalCount; + + // Porto (0% MwSt) + const totalPorto = (domesticCount * shippingPrices.portoDomestic) + + (internationalCount * shippingPrices.portoInternational); + result.shipping0Pct = totalPorto; + + // Service + Kuvert + Beschriftung (19% MwSt) - gleich für alle + const servicePerPiece = shippingPrices.serviceCharge + + shippingPrices.envelopeBase + + shippingPrices.labelingCharge; + result.shipping19Pct = servicePerPiece * quantity; + + result.total = result.shipping0Pct + result.shipping19Pct; + } else if (state.answers.shippingMode === "bulk") { + // Bulkversand: Einmalig, 0% MwSt + const shippingBulk = prices.shipping_bulk || 4.95; + result.shipping0Pct = shippingBulk; + result.total = shippingBulk; + } + + return result; +} + +/** + * Berechnet einmalige Einrichtungskosten + */ +export function calculateSetupCosts(state) { + const prices = getPrices(); + let setupTotal = 0; + + // API-Anbindung (nur bei Follow-ups mit auto mode) + if (isFollowups(state) && state.answers.followupCreateMode === "auto") { + setupTotal += prices.api_connection || 0; + } + + // Motiv Upload - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "upload") { + setupTotal += prices.motif_upload || 0; + } + + // Designservice - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "design") { + setupTotal += prices.motif_design || 0; + } + + // Textservice + if (state.answers.contentCreateMode === "textservice") { + setupTotal += prices.textservice || 0; + } + + return setupTotal; +} + +/** + * Formatiert Preis inkl/zzgl MwSt je nach Kundentyp + */ +export function formatPrice(netPrice, state, options = {}) { + const isB2B = isBusinessCustomer(state); + const taxRate = (getPrices().tax_rate || 19) / 100; + + // Option für 0% MwSt (Versand) + const actualTaxRate = options.zeroTax ? 0 : taxRate; + + const grossPrice = netPrice * (1 + actualTaxRate); + + if (isB2B) { + // Business: Netto + "zzgl. MwSt" + return { + display: `${formatEUR(netPrice)}`, + net: netPrice, + gross: grossPrice, + isB2B: true, + }; + } else { + // Endkunde: Brutto (inkl. MwSt) + return { + display: formatEUR(grossPrice), + net: netPrice, + gross: grossPrice, + isB2B: false, + }; + } +} + +/** + * Hilfsfunktion: EUR formatieren + */ +function formatEUR(amount) { + const safe = typeof amount === "number" && !isNaN(amount) ? amount : 0; + return safe.toLocaleString("de-DE", { style: "currency", currency: "EUR" }); +} + +/** + * Berechnet vollständiges Quote für Nicht-Follow-ups + */ +export function calculateStandardQuote(state) { + const quantity = state.answers.quantity || 1; + const pricePerPiece = calculatePricePerPiece(state); + const shipping = calculateShippingCosts(state); + const setup = calculateSetupCosts(state); + const taxRate = (getPrices().tax_rate || 19) / 100; + const isB2B = isBusinessCustomer(state); + + // Erst die Zeilen erstellen (pricePerPiece übergeben um doppelte Berechnung zu vermeiden) + const lines = buildQuoteLines(state, { + productNet: pricePerPiece * quantity, + pricePerPiece, + shipping, + setup, + taxRate, + }); + + // Gesamtpreise berechnen: + // - Summe aller totalGross aus den Zeilen (NICHT aus isSubItem!) + // - Alles hat 19% MwSt. (auch Porto!) + + // Gesamtsummen aus den Zeilen berechnen + // Nur Zeilen die NICHT isSubItem sind, werden zur Summe addiert + // (isSubItem sind nur Aufschlüsselungen innerhalb einer Hauptzeile) + let subtotalNet = 0; + let totalGross = 0; + + for (const line of lines) { + // Sub-Items nicht zur Summe addieren (sind bereits in Hauptzeile enthalten) + if (!line.isSubItem) { + subtotalNet += line.totalNet || 0; + totalGross += line.totalGross || 0; + } + } + + // Runden + subtotalNet = Math.round(subtotalNet * 100) / 100; + totalGross = Math.round(totalGross * 100) / 100; + + // MwSt. berechnen (Differenz zwischen Brutto und Netto) + const vatAmount = Math.round((totalGross - subtotalNet) * 100) / 100; + + // Gutschein-Rabatt berechnen + const voucher = state.order?.voucherStatus?.valid ? state.order.voucherStatus.voucher : null; + let discountAmount = 0; + let totalAfterDiscount = totalGross; + let vatAfterDiscount = vatAmount; + + if (voucher) { + if (voucher.type === 'percent') { + // Prozentual: vom Bruttopreis abziehen + discountAmount = Math.round(totalGross * (voucher.value / 100) * 100) / 100; + } else { + // Festbetrag + discountAmount = voucher.value; + } + + // Sicherstellen dass Rabatt nicht größer als Gesamtpreis ist + discountAmount = Math.min(discountAmount, totalGross); + + // Neuer Gesamtpreis + totalAfterDiscount = Math.round((totalGross - discountAmount) * 100) / 100; + + // MwSt. neu berechnen (proportional zum Rabatt) + if (totalGross > 0) { + vatAfterDiscount = Math.round(vatAmount * (totalAfterDiscount / totalGross) * 100) / 100; + } + } + + return { + currency: "EUR", + quantity, + pricePerPiece, + productNet: subtotalNet, + shipping, + setup, + subtotalNet, + vatRate: taxRate, + vatAmount: vatAfterDiscount, + totalGross: totalAfterDiscount, + discountAmount, + totalBeforeDiscount: totalGross, + voucher, + lines, + }; +} + +/** + * Erstellt Zeilen für Quote-Tabelle mit detaillierter Aufschlüsselung + */ +function buildQuoteLines(state, calc) { + const lines = []; + const quantity = state.answers.quantity || 1; + const product = state.ctx?.product; + const prices = getPrices(); + const taxRate = calc.taxRate || 0.19; + + // Produktzeile (vollständiger Preis inkl. Versand etc.) + // Hilfsfunktion für kaufmännische Rundung auf 2 Dezimalstellen + const round2 = (val) => Math.round(val * 100) / 100; + + if (product) { + // pricePerPiece aus calc verwenden (bereits in calculateStandardQuote berechnet) + // Falls nicht vorhanden, neu berechnen + const pricePerPiece = calc.pricePerPiece !== undefined + ? calc.pricePerPiece + : calculatePricePerPiece(state); + + lines.push({ + description: product.label, + unitNet: pricePerPiece, + unitGross: round2(pricePerPiece * (1 + taxRate)), + quantity, + totalNet: round2(pricePerPiece * quantity), + totalGross: round2(pricePerPiece * quantity * (1 + taxRate)), + isMainProduct: true, + }); + + // Basispreis-Zeile: Berechne aus pricePerPiece minus Versand/Kuvert-Kosten + // (statt nochmal den teuren Multiplier zu berechnen) + let basePriceWithMultiplier = pricePerPiece; + + // Versandkosten abziehen (falls Direktversand) + if (state.answers.shippingMode === "direct") { + const shippingPrices = calc.shipping?.shippingPrices || calculateDirectShippingPrices(state); + basePriceWithMultiplier -= shippingPrices.domesticPrice || 0; + } + + // Kuvert-Kosten abziehen (falls Bulk mit Kuvert) + if (state.answers.shippingMode === "bulk" && state.answers.envelope === true) { + basePriceWithMultiplier -= prices.envelope_base || 0; + if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { + basePriceWithMultiplier -= prices.envelope_labeling || prices.envelope_recipient_address || 0; + } + } + + basePriceWithMultiplier = round2(basePriceWithMultiplier); + + lines.push({ + description: "Basispreis Schriftstück", + unitNet: basePriceWithMultiplier, + unitGross: round2(basePriceWithMultiplier * (1 + taxRate)), + quantity, + totalNet: round2(basePriceWithMultiplier * quantity), + totalGross: round2(basePriceWithMultiplier * quantity * (1 + taxRate)), + isSubItem: true, + }); + + // A4 Aufpreis (nur für Einladungen und Follow-ups) + const needsA4Surcharge = + (product.key === "einladungen" || product.key === "follow-ups") && + state.answers.format === "a4"; + if (needsA4Surcharge) { + const a4Price = prices.a4_upgrade_surcharge || 0; + lines.push({ + description: "A4 Format Aufpreis", + unitNet: a4Price, + unitGross: a4Price * (1 + taxRate), + quantity, + totalNet: a4Price * quantity, + totalGross: a4Price * quantity * (1 + taxRate), + isSubItem: true, + }); + } + + // Direktversand - getrennte Zeilen für Inland und Ausland + // WICHTIG: Porto hat jetzt auch 19% MwSt. (nicht mehr 0%) + if (state.answers.shippingMode === "direct") { + const shipping = calc.shipping || {}; + const shippingPrices = shipping.shippingPrices || calculateDirectShippingPrices(state); + const domesticCount = shipping.domesticCount || 0; + const internationalCount = shipping.internationalCount || 0; + + // Service + Kuvert + Beschriftung + Porto - ALLES mit 19% MwSt + const servicePerPiece = shippingPrices.serviceCharge + + shippingPrices.envelopeBase + + shippingPrices.labelingCharge; + + // Inland-Zeile (wenn Inland-Empfänger vorhanden) + if (domesticCount > 0) { + const domesticUnitNet = shippingPrices.portoDomestic + servicePerPiece; + const domesticUnitGross = domesticUnitNet * (1 + taxRate); + const domesticTotalNet = domesticUnitNet * domesticCount; + const domesticTotalGross = domesticUnitGross * domesticCount; + + lines.push({ + description: "Direktversand mit Kuvertierung (Inland)", + unitNet: domesticUnitNet, + unitGross: domesticUnitGross, + quantity: domesticCount, + totalNet: domesticTotalNet, + totalGross: domesticTotalGross, + isSubItem: true, + isShippingLine: true, + }); + } + + // Ausland-Zeile (wenn Ausland-Empfänger vorhanden) + if (internationalCount > 0) { + const intlUnitNet = shippingPrices.portoInternational + servicePerPiece; + const intlUnitGross = intlUnitNet * (1 + taxRate); + const intlTotalNet = intlUnitNet * internationalCount; + const intlTotalGross = intlUnitGross * internationalCount; + + lines.push({ + description: "Direktversand mit Kuvertierung (Ausland)", + unitNet: intlUnitNet, + unitGross: intlUnitGross, + quantity: internationalCount, + totalNet: intlTotalNet, + totalGross: intlTotalGross, + isSubItem: true, + isShippingLine: true, + }); + } + } + + // Kuvert bei Bulkversand + if ( + state.answers.shippingMode === "bulk" && + state.answers.envelope === true + ) { + const envelopeBase = prices.envelope_base || 0; + lines.push({ + description: "Kuvert", + unitNet: envelopeBase, + unitGross: envelopeBase * (1 + taxRate), + quantity, + totalNet: envelopeBase * quantity, + totalGross: envelopeBase * quantity * (1 + taxRate), + isSubItem: true, + }); + + // Beschriftung (neues Feld: envelope_labeling, Fallback auf alte Felder) + if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") { + const labelPrice = prices.envelope_labeling || prices.envelope_recipient_address || 0; + const labelDescription = state.answers.envelopeMode === "recipientData" + ? "Beschriftung mit Empfängeradresse" + : "Beschriftung mit individuellem Text"; + lines.push({ + description: labelDescription, + unitNet: labelPrice, + unitGross: labelPrice * (1 + taxRate), + quantity, + totalNet: labelPrice * quantity, + totalGross: labelPrice * quantity * (1 + taxRate), + isSubItem: true, + }); + } + } + + // Bedruckte Karten - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "printed") { + const printedPrice = prices.motif_printed || 0; + if (printedPrice > 0) { + lines.push({ + description: "Bedruckte Karten zusenden", + unitNet: printedPrice, + unitGross: printedPrice * (1 + taxRate), + quantity, + totalNet: printedPrice * quantity, + totalGross: printedPrice * quantity * (1 + taxRate), + isSubItem: true, + }); + } + } + } + + // Sammelversand (einmalig) - jetzt auch mit 19% MwSt + if (state.answers.shippingMode === "bulk") { + const bulkPrice = prices.shipping_bulk || 0; + lines.push({ + description: "Sammelversand (einmalig)", + unitNet: bulkPrice, + unitGross: bulkPrice * (1 + taxRate), + quantity: 1, + totalNet: bulkPrice, + totalGross: bulkPrice * (1 + taxRate), + }); + } + + // Einmalige Setup-Kosten + const setupCosts = []; + + // Motiv Upload - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "upload") { + const uploadPrice = prices.motif_upload || 0; + if (uploadPrice > 0) { + setupCosts.push({ + description: "Motiv Upload (einmalig)", + unitNet: uploadPrice, + unitGross: uploadPrice * (1 + taxRate), + quantity: 1, + totalNet: uploadPrice, + totalGross: uploadPrice * (1 + taxRate), + }); + } + } + + // Designservice - NUR wenn motifNeed = true + if (state.answers.motifNeed === true && state.answers.motifSource === "design") { + const designPrice = prices.motif_design || 0; + if (designPrice > 0) { + setupCosts.push({ + description: "Designservice (einmalig)", + unitNet: designPrice, + unitGross: designPrice * (1 + taxRate), + quantity: 1, + totalNet: designPrice, + totalGross: designPrice * (1 + taxRate), + }); + } + } + + // Textservice + if (state.answers.contentCreateMode === "textservice") { + const textPrice = prices.textservice || 0; + if (textPrice > 0) { + setupCosts.push({ + description: "Textservice (einmalig)", + unitNet: textPrice, + unitGross: textPrice * (1 + taxRate), + quantity: 1, + totalNet: textPrice, + totalGross: textPrice * (1 + taxRate), + }); + } + } + + // API-Anbindung (nur bei Follow-ups) + if (isFollowups(state) && state.answers.followupCreateMode === "auto") { + const apiPrice = prices.api_connection || 0; + if (apiPrice > 0) { + setupCosts.push({ + description: "API-Anbindung (einmalig)", + unitNet: apiPrice, + unitGross: apiPrice * (1 + taxRate), + quantity: 1, + totalNet: apiPrice, + totalGross: apiPrice * (1 + taxRate), + }); + } + } + + return [...lines, ...setupCosts]; +} + +/** + * Berechnet Follow-up Preisstaffelung (für Tabelle) + */ +export function calculateFollowupPricing(state) { + if (!isFollowups(state)) return null; + + const pricePerPiece = calculatePricePerPiece(state); + const taxRate = (getPrices().tax_rate || 19) / 100; + const isB2B = isBusinessCustomer(state); + + // Preis-Stufen basierend auf monatlichem Volumen + const tiers = [ + { min: 5, max: 49, label: "5-49 Stück/Monat" }, + { min: 50, max: 199, label: "50-199 Stück/Monat" }, + { min: 200, max: 499, label: "200-499 Stück/Monat" }, + { min: 500, max: 999, label: "500-999 Stück/Monat" }, + { min: 1000, max: null, label: "1000+ Stück/Monat" }, + ]; + + const pricing = tiers.map((tier) => { + const avgVolume = tier.max ? (tier.min + tier.max) / 2 : 1000; + const multiplier = getFollowupMultiplier(avgVolume); + const piecePrice = pricePerPiece / multiplier || 0; // Basispreis vor Multiplikator + const monthlyNet = piecePrice * avgVolume * multiplier; + const monthlyGross = monthlyNet * (1 + taxRate); + + return { + label: tier.label, + multiplier, + pricePerPiece: piecePrice * multiplier, + monthlyNet, + monthlyGross, + displayNet: formatEUR(monthlyNet), + displayGross: formatEUR(monthlyGross), + }; + }); + + return { + tiers: pricing, + setup: calculateSetupCosts(state), + shippingNote: null, // Bei Follow-ups keine Versandkosten-Meldung + isB2B, + }; +} + +export { formatEUR }; diff --git a/skrift-configurator/assets/js/configurator-state.js b/skrift-configurator/assets/js/configurator-state.js new file mode 100644 index 0000000..d986f20 --- /dev/null +++ b/skrift-configurator/assets/js/configurator-state.js @@ -0,0 +1,1030 @@ +// Skrift Konfigurator State - Überarbeitet +// Optimierter Frageprozess mit bedingter Logik + +// Import Pricing Logic +import { calculateStandardQuote } from "./configurator-pricing.js"; + +// Basis-Produktdefinitionen (werden mit Backend-Settings überschrieben) +const PRODUCT_BASE_CONFIG = { + businessbriefe: { + key: "businessbriefe", + formats: ["a4"], + category: "business", + supportsMotif: false, + }, + "business-postkarten": { + key: "business-postkarten", + formats: ["a6p", "a6l"], + category: "business", + supportsMotif: true, + }, + "follow-ups": { + key: "follow-ups", + formats: ["a4", "a6p", "a6l"], + category: "business", + supportsMotif: true, + isFollowUp: true, + }, + einladungen: { + key: "einladungen", + formats: ["a4", "a6p", "a6l"], + category: "private", + supportsMotif: true, + }, + "private-briefe": { + key: "private-briefe", + formats: ["a4"], + category: "private", + supportsMotif: false, + }, +}; + +// Produktdefinitionen mit Backend-Settings mergen +function getProductDefinitions() { + const settings = window.SkriftConfigurator?.settings?.products || {}; + const products = {}; + + for (const [key, baseConfig] of Object.entries(PRODUCT_BASE_CONFIG)) { + const backendSettings = settings[key] || {}; + products[key] = { + ...baseConfig, + label: backendSettings.label || key, + description: backendSettings.description || 'Professionelle handgeschriebene Korrespondenz', + basePrice: parseFloat(backendSettings.base_price) || 2.50, + }; + } + + return products; +} + +const PRODUCT_BY_PARAM = getProductDefinitions(); + +export const STEPS = { + PRODUCT: 0, // Kundentyp + Produktauswahl zusammen + QUANTITY: 1, // Mengenabfrage + ENVELOPE: 2, // Versand + Umschlag zusammen + CONTENT: 3, + CUSTOMER_DATA: 4, + REVIEW: 5, +}; + +export function deriveContextFromUrl(search) { + const q = new URLSearchParams(search); + const keys = Array.from(q.keys()); + + // Produkt ermitteln (erster Key der ein Produkt ist, oder 'product' Parameter) + let product = null; + let urlParam = null; + + // Prüfe ob ein Produkt-Schlüssel direkt als Parameter vorhanden ist + for (const key of keys) { + if (PRODUCT_BY_PARAM[key]) { + product = PRODUCT_BY_PARAM[key]; + urlParam = key; + break; + } + } + + // Quantity aus URL + const quantityParam = q.get('quantity'); + const quantity = quantityParam ? parseInt(quantityParam, 10) : null; + + // Format aus URL (a4, a6h = A6 Hochformat, a6q = A6 Querformat) + const formatParam = q.get('format')?.toLowerCase(); + let format = null; + if (formatParam === 'a4') format = 'a4'; + else if (formatParam === 'a6h') format = 'a6p'; // Hochformat + else if (formatParam === 'a6q') format = 'a6l'; // Querformat + + // noPrice Parameter (Preise ausblenden) + const noPrice = q.has('noPrice') || q.has('noprice'); + + // noLimits Parameter (keine Mindestmengen) + const noLimits = q.has('noLimits') || q.has('nolimits'); + + return { + urlParam: urlParam || null, + product, + quantity: quantity && !isNaN(quantity) && quantity > 0 ? quantity : null, + format, + noPrice, + noLimits, + }; +} + +export function normalizePlaceholderName(raw) { + return String(raw || "") + .toLowerCase() + .replace(/\s+/g, ""); +} + +export function extractPlaceholders(text) { + const out = new Set(); + const re = /\[\[([^\]]+)\]\]/g; + let m; + while ((m = re.exec(String(text || "")))) { + const name = normalizePlaceholderName(m[1]); + if (name) out.add(name); + } + return Array.from(out); +} + +export function getEnvelopeTypeByFormat(format) { + if (format === "a4") return "dinlang"; + if (format === "a6p" || format === "a6l") return "c6"; + return null; +} + +export function createInitialState(ctx) { + const hasPreselectedProduct = !!ctx?.product?.key; + + // Wenn Produkt vorgewählt, Produktauswahl überspringen + let initialStep = STEPS.PRODUCT; + let initialCustomerType = null; + + if (hasPreselectedProduct) { + initialStep = STEPS.QUANTITY; + initialCustomerType = + ctx.product.category === "business" ? "business" : "private"; + } + + // URL-Parameter für Menge und Format + const urlQuantity = ctx?.quantity || null; + const urlFormat = ctx?.format || null; + + // Wenn Produkt + Quantity + Format aus URL: Direkt zu Umschlag-Step springen + if (hasPreselectedProduct && urlQuantity && urlFormat) { + // Prüfen ob Format vom Produkt unterstützt wird + const supportedFormats = ctx.product.formats || []; + if (supportedFormats.includes(urlFormat)) { + initialStep = STEPS.ENVELOPE; + } + } + + // Standardmenge auf beste Preismenge (normalQuantity) setzen, außer URL-Parameter + const dynamicPricing = window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + const isB2B = initialCustomerType === "business"; + const defaultQuantity = urlQuantity || (isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50)); + + // Format aus URL oder null + const initialFormat = urlFormat || null; + + // noPrice Mode aus URL + const noPrice = ctx?.noPrice || false; + + // noLimits Mode aus URL (keine Mindestmengen) + const noLimits = ctx?.noLimits || false; + + return { + step: initialStep, + history: [], + ctx, + noPrice, // Preise ausblenden wenn true + noLimits, // Keine Mindestmengen wenn true + + quote: { + currency: "EUR", + subtotalNet: 0, + vatRate: 0.19, + vatAmount: 0, + totalGross: 0, + lines: [], + }, + + answers: { + customerType: initialCustomerType, + quantity: defaultQuantity, + format: initialFormat, + + // Follow-ups Spezifisch + followupYearlyVolume: null, // Neu: 5-49, 50-199, etc. + followupCreateMode: null, // 'auto' | 'manual' + followupSourceSystem: "", + followupTriggerDescription: "", // Neu: Was löst Follow-up aus + followupCheckCycle: "monthly", + + // Versand + shippingMode: null, // 'direct' | 'bulk' + + // Umschlag + envelope: null, + envelopeLabeled: null, + envelopeMode: null, // 'recipientData' | 'customText' | 'none' + envelopeCustomText: "", + + // Inhalt + contentCreateMode: null, // 'self' | 'textservice' + letterText: "", + + // Motiv + motifNeed: null, + motifSource: null, // 'upload' | 'printed' | 'design' + motifFileName: "", + motifFileMeta: null, + + // Services + serviceText: false, + serviceDesign: false, + serviceApi: false, + }, + + recipientRows: [], + // Adressmodus: 'classic' (Name, Anschrift) oder 'free' (5 freie Zeilen) + addressMode: 'classic', + // Freie Adresszeilen (separat gespeichert, damit beim Wechsel nichts verloren geht) + freeAddressRows: [], + placeholders: { + envelope: [], + letter: [], + }, + placeholderValues: {}, + + order: { + billing: { + firstName: "", + lastName: "", + company: "", + email: "", + phone: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: "Deutschland", + }, + shippingDifferent: false, + shipping: { + firstName: "", + lastName: "", + company: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: "Deutschland", + }, + acceptedAgb: false, + acceptedPrivacy: false, + }, + }; +} + +// Helper Functions +export function isFollowups(state) { + return state?.ctx?.product?.isFollowUp === true; +} + +export function isInvitation(state) { + return state?.ctx?.product?.key === "einladungen"; +} + +export function isPostcardLike(state) { + const k = state?.ctx?.product?.key; + return ( + k === "business-postkarten" || + (k === "follow-ups" && + (state.answers.format === "a6p" || state.answers.format === "a6l")) + ); +} + +export function supportsMotif(state) { + const product = state?.ctx?.product; + if (!product?.supportsMotif) return false; + + // Postkarten: immer Motiv möglich (nur A6 verfügbar) + if (product.key === "business-postkarten") return true; + + // Einladungen und Follow-ups: nur bei A6 Format + if (product.key === "einladungen" || product.key === "follow-ups") { + return state.answers.format === "a6p" || state.answers.format === "a6l"; + } + + return false; +} + +export function productSupportsFormat(state, format) { + const formats = state?.ctx?.product?.formats || []; + return formats.includes(format); +} + +export function calcEffectiveEnvelopeType(state) { + return getEnvelopeTypeByFormat(state.answers.format); +} + +export function getAvailableProductsForCustomerType(customerType) { + return Object.values(PRODUCT_BY_PARAM).filter( + (p) => p.category === customerType + ); +} + +export function syncPlaceholders(state) { + const pEnv = + state.answers.envelopeMode === "customText" + ? extractPlaceholders(state.answers.envelopeCustomText) + : []; + + const pLetter = extractPlaceholders(state.answers.letterText); + + const builtIn = + state.answers.envelopeMode === "recipientData" ? ["vorname", "name"] : []; + + return { + ...state, + placeholders: { + envelope: pEnv, + letter: Array.from(new Set([...pLetter, ...builtIn])), + }, + }; +} + +export function requiredRowCount(state) { + const q = Number(state.answers.quantity); + return Number.isFinite(q) && q > 0 ? q : 0; +} + +export function ensurePlaceholderArrays(state) { + const rows = requiredRowCount(state); + const all = new Set([ + ...state.placeholders.envelope, + ...state.placeholders.letter, + ]); + + const nextValues = { ...state.placeholderValues }; + + for (const name of all) { + if (!Array.isArray(nextValues[name])) nextValues[name] = []; + if (nextValues[name].length < rows) { + nextValues[name] = [ + ...nextValues[name], + ...new Array(rows - nextValues[name].length).fill(""), + ]; + } else if (nextValues[name].length > rows) { + nextValues[name] = nextValues[name].slice(0, rows); + } + } + + return { ...state, placeholderValues: nextValues }; +} + +export function validateStep(state) { + const s = state.step; + + if (s === STEPS.PRODUCT) { + // Kundentyp und Produkt müssen gewählt sein + if (!state.answers.customerType) return false; + return !!state?.ctx?.product?.key; + } + + if (s === STEPS.QUANTITY) { + // Bei Follow-ups keine Mengenabfrage, nur Volumen + if (!isFollowups(state)) { + const qty = Number(state.answers.quantity); + if (!Number.isFinite(qty) || qty <= 0) return false; + } + + // Format Auswahl validieren + const formats = state?.ctx?.product?.formats || []; + if (formats.length > 0 && !state.answers.format) return false; + + // Follow-ups spezifische Validierung + if (isFollowups(state)) { + if (!state.answers.followupYearlyVolume) return false; + if (!state.answers.followupCreateMode) return false; + + if (state.answers.followupCreateMode === "auto") { + if (!state.answers.followupSourceSystem?.trim()) return false; + if (!state.answers.followupTriggerDescription?.trim()) return false; + } + } + + return true; + } + + if (s === STEPS.ENVELOPE) { + // Versandart muss gewählt sein + if (!state.answers.shippingMode) return false; + + // Bei Versand durch Skrift ist Umschlag automatisch + if (state.answers.shippingMode === "direct") { + // Automatisch gesetzt, keine Validierung nötig für envelope + if (!state.answers.envelopeMode) return false; + + // Bei Follow-ups: keine Empfängerdaten-Validierung (kommt aus CRM) + // Bei regulären Produkten: Empfängerdaten müssen vorhanden sein + if (!isFollowups(state)) { + // Empfängerdaten validieren für reguläre Produkte + if (state.answers.envelopeMode === "recipientData") { + const addressMode = state.addressMode || 'classic'; + + if (addressMode === 'free') { + // Freie Adresse: mindestens Zeile 1 muss ausgefüllt sein + const rows = state.freeAddressRows || []; + if (rows.length !== requiredRowCount(state)) return false; + + for (const r of rows) { + if (!r) return false; + // Mindestens Zeile 1 muss ausgefüllt sein + if (!String(r.line1 || "").trim()) return false; + } + } else { + // Klassische Adresse + const rows = state.recipientRows || []; + if (rows.length !== requiredRowCount(state)) return false; + + for (const r of rows) { + if (!r) return false; + const required = [ + "firstName", + "lastName", + "street", + "houseNumber", + "zip", + "city", + "country", + ]; + for (const k of required) { + if (!String(r[k] || "").trim()) return false; + } + } + } + } + } + } else { + // Bulk versand + if (state.answers.envelope === null) return false; + + if (state.answers.envelope === true) { + // Beschriftungsmodus muss gewählt sein + if (!state.answers.envelopeMode) return false; + + // Empfängerdaten validieren für Bulk-Versand (nur wenn Umschlag gewählt) + if (state.answers.envelopeMode === "recipientData") { + const addressMode = state.addressMode || 'classic'; + + if (addressMode === 'free') { + // Freie Adresse: mindestens Zeile 1 muss ausgefüllt sein + const rows = state.freeAddressRows || []; + if (rows.length !== requiredRowCount(state)) return false; + + for (const r of rows) { + if (!r) return false; + if (!String(r.line1 || "").trim()) return false; + } + } else { + // Klassische Adresse + const rows = state.recipientRows || []; + if (rows.length !== requiredRowCount(state)) return false; + + for (const r of rows) { + if (!r) return false; + const required = [ + "firstName", + "lastName", + "street", + "houseNumber", + "zip", + "city", + "country", + ]; + for (const k of required) { + if (!String(r[k] || "").trim()) return false; + } + } + } + } + + // Custom Text validieren (nur wenn Umschlag gewählt) + if (state.answers.envelopeMode === "customText") { + if (!state.answers.envelopeCustomText?.trim()) return false; + + if (state.placeholders.envelope.length > 0) { + for (const ph of state.placeholders.envelope) { + const arr = state.placeholderValues[ph] || []; + if (arr.length !== requiredRowCount(state)) return false; + if (arr.some((v) => !String(v || "").trim())) return false; + } + } + } + } + // Wenn envelope === false, keine weitere Validierung nötig + } + + return true; + } + + if (s === STEPS.CONTENT) { + if (!state.answers.contentCreateMode) return false; + + // Wenn self, muss Text vorhanden sein + if (state.answers.contentCreateMode === "self") { + if (!state.answers.letterText?.trim()) return false; + + // Platzhalter validieren + const usedLetter = extractPlaceholders(state.answers.letterText); + + // Platzhalter validieren + // "vorname", "name", "ort" können immer aus recipientRows kommen (bei shippingMode=direct oder envelopeMode=recipientData) + const recipientPlaceholders = new Set(["vorname", "name", "ort"]); + const hasRecipientData = state.answers.shippingMode === "direct" || state.answers.envelopeMode === "recipientData"; + + // Platzhalter aus dem Umschlag + const envSet = new Set(state.placeholders.envelope || []); + + // Platzhalter die validiert werden müssen (nicht aus Umschlag, nicht aus recipientRows) + const needed = usedLetter.filter((p) => { + if (envSet.has(p)) return false; // aus Umschlag + if (hasRecipientData && recipientPlaceholders.has(p)) return false; // aus recipientRows + return true; + }); + + // Nur die übrigen Platzhalter validieren + for (const ph of needed) { + const arr = state.placeholderValues[ph] || []; + if (arr.length !== requiredRowCount(state)) return false; + if (arr.some((v) => !String(v || "").trim())) return false; + } + } + + // Motiv Validierung + if (supportsMotif(state)) { + if (state.answers.motifNeed === null) return false; + + if (state.answers.motifNeed === true) { + if (!state.answers.motifSource) return false; + + // Nur bei Upload muss eine Datei vorhanden sein + // Bei "printed" (bedruckte Karten) und "design" (Designservice) nicht + if (state.answers.motifSource === "upload") { + if (!state.answers.motifFileName) return false; + } + } + } + + return true; + } + + if (s === STEPS.CUSTOMER_DATA) { + const b = state.order.billing; + // houseNumber entfernt - ist jetzt Teil von street + const req = [ + "firstName", + "lastName", + "email", + "phone", + "street", + "zip", + "city", + "country", + ]; + + for (const k of req) { + if (!String(b[k] || "").trim()) return false; + } + + // E-Mail Format validieren + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(b.email)) return false; + + if (state.answers.customerType === "business") { + if (!String(b.company || "").trim()) return false; + } + + if (state.order.shippingDifferent) { + const sh = state.order.shipping; + // Shipping braucht kein E-Mail/Telefon - nur Adressfelder + const shippingReq = ["firstName", "lastName", "street", "zip", "city", "country"]; + for (const k of shippingReq) { + if (!String(sh[k] || "").trim()) return false; + } + } + + return true; + } + + if (s === STEPS.REVIEW) { + if (!state.order.acceptedAgb) return false; + if (!state.order.acceptedPrivacy) return false; + return true; + } + + return false; +} + +// Preisrelevante Felder die eine Neuberechnung auslösen +const PRICE_RELEVANT_FIELDS = new Set([ + 'quantity', + 'format', + 'shippingMode', + 'envelope', + 'envelopeMode', + 'motifSource', + 'motifNeed', + 'contentCreateMode', + 'followupYearlyVolume', + 'followupCreateMode', + 'customerType', +]); + +/** + * Prüft ob ein Patch preisrelevante Änderungen enthält + */ +function hasPriceRelevantChanges(patch) { + if (!patch) return false; + return Object.keys(patch).some(key => PRICE_RELEVANT_FIELDS.has(key)); +} + +/** + * Zählt Inland/Ausland-Empfänger für Vergleich + */ +function countCountryDistribution(rows, addressMode) { + let domestic = 0; + let international = 0; + + if (!Array.isArray(rows)) return { domestic, international }; + + for (const row of rows) { + if (!row) continue; + + let country = ''; + if (addressMode === 'free') { + country = row.line5 || ''; + } else { + country = row.country || ''; + } + + const countryLower = country.toLowerCase().trim(); + const isDomestic = !countryLower || + countryLower === 'deutschland' || + countryLower === 'germany' || + countryLower === 'de' || + countryLower === 'ger'; + + if (isDomestic) { + domestic++; + } else { + international++; + } + } + + return { domestic, international }; +} + +/** + * Berechnet Quote neu basierend auf aktuellem State + * Wird nur aufgerufen wenn preisrelevante Änderungen vorliegen + */ +function recalculateQuote(state, forceRecalculate = false) { + // Nur für Nicht-Follow-ups Quote berechnen + if (!isFollowups(state)) { + try { + const quote = calculateStandardQuote(state); + return { ...state, quote }; + } catch (e) { + console.error("Error calculating quote:", e); + } + } + return state; +} + +export function reducer(state, action) { + switch (action.type) { + case "HYDRATE_ALL": { + // Alle gespeicherten Daten in EINEM Durchgang wiederherstellen + const p = action.payload; + let next = { ...state }; + + // Produkt wiederherstellen + if (p.productKey) { + const products = listProducts(); + const savedProduct = products.find((prod) => prod.key === p.productKey); + if (savedProduct) { + next.ctx = { ...next.ctx, product: savedProduct }; + } + } + + // Answers wiederherstellen (URL-Parameter haben Vorrang) + const answerPatch = { ...p.answers }; + if (p.urlQuantity !== null) delete answerPatch.quantity; + if (p.urlFormat !== null) delete answerPatch.format; + next.answers = { ...next.answers, ...answerPatch }; + + // Empfängerdaten + if (Array.isArray(p.recipientRows)) { + next.recipientRows = p.recipientRows; + } + if (p.addressMode) { + next.addressMode = p.addressMode; + } + if (Array.isArray(p.freeAddressRows)) { + next.freeAddressRows = p.freeAddressRows; + } + if (p.placeholderValues && typeof p.placeholderValues === "object") { + next.placeholderValues = p.placeholderValues; + } + + // Order + if (p.order && typeof p.order === "object") { + next.order = { + ...next.order, + billing: p.order.billing || next.order?.billing, + shipping: p.order.shipping || next.order?.shipping, + shippingDifferent: !!p.order.shippingDifferent, + acceptedAgb: false, + acceptedPrivacy: false, + }; + } + + // Step wiederherstellen (nur wenn nicht im PRODUCT Step) + if (typeof p.step === "number" && p.step >= 0 && p.currentStep !== STEPS.PRODUCT) { + next.step = p.step; + next.history = []; + for (let i = 0; i < p.step; i++) { + next.history.push(i); + } + } + + // Quote einmal am Ende berechnen + next = recalculateQuote(next); + + return next; + } + + case "SET_CUSTOMER_TYPE": { + // Bleibt im PRODUCT Step, zeigt jetzt Produktauswahl an + return { + ...state, + answers: { ...state.answers, customerType: action.customerType }, + }; + } + + case "SET_PRODUCT": { + let next = { + ...state, + ctx: { ...state.ctx, product: action.product }, + }; + + // Format zurücksetzen wenn nicht unterstützt + if ( + next.answers.format && + !productSupportsFormat(next, next.answers.format) + ) { + next.answers = { ...next.answers, format: null }; + } + + // Follow-ups: Automatisch Direktversand setzen + if (action.product?.key === "follow-ups") { + next.answers = { ...next.answers, shippingMode: "direct" }; + } + + return next; + } + + case "RESTORE_PRODUCT": { + // Wie SET_PRODUCT, aber ohne Step zu ändern (für localStorage-Hydration) + let next = { + ...state, + ctx: { ...state.ctx, product: action.product }, + }; + + // Format zurücksetzen wenn nicht unterstützt + if ( + next.answers.format && + !productSupportsFormat(next, next.answers.format) + ) { + next.answers = { ...next.answers, format: null }; + } + + // Follow-ups: Automatisch Direktversand setzen + if (action.product?.key === "follow-ups") { + next.answers = { ...next.answers, shippingMode: "direct" }; + } + + return next; + } + + case "SET_STEP": { + return { ...state, step: action.step }; + } + + case "ANSWER": { + let next = { ...state, answers: { ...state.answers, ...action.patch } }; + + // WICHTIG: Preview Cache löschen wenn envelopeMode geändert wird + if (action.patch && "envelopeMode" in action.patch) { + console.log('[State] envelopeMode changed, clearing envelope previews'); + if (window.envelopePreviewManager) { + window.envelopePreviewManager.currentBatchPreviews = []; + window.envelopePreviewManager.currentDocIndex = 0; + } + } + + // KEIN automatisches Preview-Löschen mehr für andere Änderungen + // Benutzer muss auf "Vorschau generieren" klicken um neu zu laden + // (verwendet dann 1 Request) + + // Auto-Logik für Versand -> Umschlag + if (action.patch && "shippingMode" in action.patch) { + if (action.patch.shippingMode === "direct") { + next.answers = { + ...next.answers, + envelope: true, + envelopeMode: "recipientData", + }; + // Bei Einzelversand: Leere Länder-Felder auf "Deutschland" setzen (Default) + if (Array.isArray(next.recipientRows)) { + next.recipientRows = next.recipientRows.map(row => ({ + ...row, + // Nur setzen wenn Feld leer ist + country: row.country && row.country.trim() !== "" ? row.country : "Deutschland" + })); + } + } + } + + // Auto-Logik für Kundentyp-Wechsel: Standardmenge anpassen NUR wenn noch nicht gesetzt + if (action.patch && "customerType" in action.patch) { + // Nur setzen wenn Menge noch nicht vom Benutzer geändert wurde + // (d.h. wenn sie noch dem alten Default entspricht oder nicht gesetzt ist) + const currentQty = next.answers.quantity; + const dynamicPricing = window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + + // Alte Default-Werte berechnen + const wasB2B = state.answers.customerType === "business"; + const oldDefaultQty = wasB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + // Nur überschreiben wenn Menge noch dem alten Default entspricht oder nicht gesetzt ist + if (!currentQty || currentQty === oldDefaultQty || currentQty === 1) { + const isB2B = action.patch.customerType === "business"; + const newDefaultQuantity = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + next.answers = { ...next.answers, quantity: newDefaultQuantity }; + } + } + + // Auto-Logik für textservice + if (action.patch && "contentCreateMode" in action.patch) { + if (action.patch.contentCreateMode === "textservice") { + next.answers = { ...next.answers, serviceText: true }; + } else { + next.answers = { ...next.answers, serviceText: false }; + } + } + + // Auto-Logik für Motiv + Designservice + if (action.patch && "motifSource" in action.patch) { + if (action.patch.motifSource === "design") { + next.answers = { ...next.answers, serviceDesign: true }; + } + } + + // Auto-Logik für motifNeed: Wenn kein Motiv gewählt, motifSource zurücksetzen + if (action.patch && "motifNeed" in action.patch) { + if (action.patch.motifNeed === false) { + next.answers = { ...next.answers, motifSource: null, serviceDesign: false }; + } + } + + // Auto-Logik für Format-Wechsel: Bei A4 nur "printed" erlaubt + if (action.patch && "format" in action.patch) { + if (action.patch.format === "a4") { + // Wenn upload oder design ausgewählt war, auf printed umstellen + if (next.answers.motifSource === "upload" || next.answers.motifSource === "design") { + next.answers = { ...next.answers, motifSource: "printed" }; + } + } + } + + next = syncPlaceholders(next); + next = ensurePlaceholderArrays(next); + + // Quote nur neu berechnen wenn preisrelevante Änderungen vorliegen + if (hasPriceRelevantChanges(action.patch)) { + next = recalculateQuote(next); + } + + return next; + } + + case "SET_RECIPIENT_ROWS": { + // Quote nur neu berechnen wenn sich Inland/Ausland-Verteilung geändert hat + let next = { ...state, recipientRows: action.rows }; + if (state.answers?.shippingMode === "direct") { + const oldDist = countCountryDistribution(state.recipientRows, state.addressMode); + const newDist = countCountryDistribution(action.rows, state.addressMode); + if (oldDist.domestic !== newDist.domestic || oldDist.international !== newDist.international) { + next = recalculateQuote(next); + } + } + return next; + } + + case "SET_ADDRESS_MODE": { + // Quote neu berechnen da Adressmodus die Länder-Erkennung beeinflusst + let next = { ...state, addressMode: action.mode }; + if (state.answers?.shippingMode === "direct") { + next = recalculateQuote(next); + } + return next; + } + + case "SET_FREE_ADDRESS_ROWS": { + // Quote nur neu berechnen wenn sich Inland/Ausland-Verteilung geändert hat + let next = { ...state, freeAddressRows: action.rows }; + if (state.answers?.shippingMode === "direct") { + const oldDist = countCountryDistribution(state.freeAddressRows, 'free'); + const newDist = countCountryDistribution(action.rows, 'free'); + if (oldDist.domestic !== newDist.domestic || oldDist.international !== newDist.international) { + next = recalculateQuote(next); + } + } + return next; + } + + case "SET_PLACEHOLDER_VALUE": { + const name = normalizePlaceholderName(action.name); + const row = Number(action.row); + const value = String(action.value ?? ""); + + const nextValues = { ...state.placeholderValues }; + const arr = Array.isArray(nextValues[name]) ? [...nextValues[name]] : []; + const rows = requiredRowCount(state); + + while (arr.length < rows) arr.push(""); + if (row >= 0 && row < rows) arr[row] = value; + + nextValues[name] = arr; + + return { ...state, placeholderValues: nextValues }; + } + + case "SET_PLACEHOLDER_VALUES": { + // Komplettes Platzhalter-Objekt setzen (analog zu SET_RECIPIENT_ROWS) + return { ...state, placeholderValues: action.values || {} }; + } + + case "SET_ORDER": { + const nextState = { ...state, order: { ...state.order, ...action.patch } }; + // Quote neu berechnen wenn Gutschein geändert wurde + return recalculateQuote(nextState); + } + + case "SET_ORDER_BILLING": { + return { + ...state, + order: { + ...state.order, + billing: { ...state.order.billing, ...action.patch }, + }, + }; + } + + case "SET_ORDER_SHIPPING": { + return { + ...state, + order: { + ...state.order, + shipping: { ...state.order.shipping, ...action.patch }, + }, + }; + } + + case "NAV_NEXT": { + const ok = validateStep(state); + if (!ok) return state; + + const nextStep = Math.min(STEPS.REVIEW, state.step + 1); + + // Nach oben scrollen + window.scrollTo({ top: 0, behavior: 'smooth' }); + + return { + ...state, + history: [...state.history, state.step], + step: nextStep, + }; + } + + case "NAV_PREV": { + const hist = state.history || []; + if (hist.length === 0) return state; + const prev = hist[hist.length - 1]; + + // Nach oben scrollen + window.scrollTo({ top: 0, behavior: 'smooth' }); + + return { ...state, step: prev, history: hist.slice(0, -1) }; + } + + default: + return state; + } +} + +export function listProducts() { + return Object.values(PRODUCT_BY_PARAM); +} diff --git a/skrift-configurator/assets/js/configurator-ui.js b/skrift-configurator/assets/js/configurator-ui.js new file mode 100644 index 0000000..248d75e --- /dev/null +++ b/skrift-configurator/assets/js/configurator-ui.js @@ -0,0 +1,5446 @@ +import { + STEPS, + listProducts, + isFollowups, + isInvitation, + isPostcardLike, + supportsMotif, + calcEffectiveEnvelopeType, + extractPlaceholders, + normalizePlaceholderName, + validateStep, + getAvailableProductsForCustomerType, +} from "./configurator-state.js?ver=0.3.0"; + +import { + calculatePricePerPiece, + calculateFollowupBasePricePerPiece, + formatPrice, + calculateFollowupPricing, + calculateDirectShippingPrices, + formatEUR as fmtEUR, +} from "./configurator-pricing.js"; + +import BackendIntegration from "./configurator-backend-integration.js"; + +/* Persistenz */ +const LS_KEY = "skrift.configurator.v2"; +let hydratedForProductKey = null; + +// Globaler State-Tracker für Modals +let currentGlobalState = null; + +// Registry für Tabellen-Save-Funktionen (für flush vor Navigation) +const tableSaveRegistry = new Set(); + +/** + * Registriert eine Save-Funktion für eine Tabelle + * @param {Function} saveFn - Funktion zum Speichern der lokalen Änderungen + * @returns {Function} - Unregister-Funktion + */ +function registerTableSave(saveFn) { + tableSaveRegistry.add(saveFn); + return () => tableSaveRegistry.delete(saveFn); +} + +/** + * Speichert alle registrierten Tabellen (lokale Änderungen -> State) + * Wird vor Navigation und bei Page Leave aufgerufen + */ +export function flushAllTables() { + tableSaveRegistry.forEach((saveFn) => { + try { + saveFn(); + } catch (e) { + console.warn("[UI] Error flushing table:", e); + } + }); +} + +// Performante Persistenz: Nur periodisch + bei Page Leave +let persistDirty = false; +let persistInterval = null; +const PERSIST_INTERVAL_MS = 60000; // 1 Minute + +function markPersistDirty() { + persistDirty = true; +} + +function flushPersistIfDirty() { + if (persistDirty && currentGlobalState) { + writePersistedNow(currentGlobalState); + persistDirty = false; + } +} + +function startPersistInterval() { + if (persistInterval) return; + persistInterval = setInterval(flushPersistIfDirty, PERSIST_INTERVAL_MS); + + // Page Leave Detection: beforeunload, visibilitychange, pagehide + window.addEventListener("beforeunload", flushPersistIfDirty); + window.addEventListener("pagehide", flushPersistIfDirty); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + flushPersistIfDirty(); + } + }); +} + +function stopPersistInterval() { + if (persistInterval) { + clearInterval(persistInterval); + persistInterval = null; + } +} + +// ========== Validierungs-Overlay und Overflow-Warnung ========== + +/** + * Zeigt Lade-Overlay während der Textvalidierung + */ +export function showValidationOverlay() { + // Entferne vorhandenes Overlay + hideValidationOverlay(); + + const overlay = document.createElement("div"); + overlay.id = "sk-validation-overlay"; + overlay.className = "sk-validation-overlay"; + overlay.innerHTML = ` +
+
Text wird geprüft...
+ `; + document.body.appendChild(overlay); +} + +/** + * Versteckt das Lade-Overlay + */ +export function hideValidationOverlay() { + const overlay = document.getElementById("sk-validation-overlay"); + if (overlay) { + overlay.remove(); + } +} + +/** + * Zeigt Overflow-Warnung mit betroffenen Briefen + * @param {Array} overflowFiles - Array von { index, lineCount, lineLimit } + * @param {HTMLElement} container - Container für die Warnung + */ +export function showOverflowWarning(overflowFiles, container) { + // Entferne vorherige Warnung + hideOverflowWarning(container); + + if (!overflowFiles || overflowFiles.length === 0) return; + + const warning = document.createElement("div"); + warning.className = "sk-overflow-warning"; + warning.id = "sk-overflow-warning"; + + // Max 10 Fehler anzeigen + const maxDisplay = 10; + const displayFiles = overflowFiles.slice(0, maxDisplay); + const remaining = overflowFiles.length - maxDisplay; + + const itemsHtml = displayFiles + .map((f) => { + // Zeige Tabellenzeile (index + 1 für 1-basierte Anzeige) + const rowNumber = f.index + 1; + return ` +
  • + Empfänger in Tabellenzeile ${rowNumber} + ${f.lineCount} / ${f.lineLimit} Zeilen +
  • + `; + }) + .join(""); + + // "und X weitere" Hinweis + const remainingHtml = + remaining > 0 + ? `
  • + ... und ${remaining} weitere Empfänger + +
  • ` + : ""; + + warning.innerHTML = ` +
    + Text zu lang für das gewählte Format +
    +
      + ${itemsHtml} + ${remainingHtml} +
    +

    + Bitte kürzen Sie den Text oder reduzieren Sie die Zeilenanzahl, um fortzufahren. + Die Zeilennummern beziehen sich auf die Empfänger-/Platzhaltertabelle. +

    + `; + + // Am Anfang des Containers einfügen + container.insertBefore(warning, container.firstChild); + + // Scroll zur Warnung + warning.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +/** + * Versteckt die Overflow-Warnung + */ +export function hideOverflowWarning(container) { + const warning = container?.querySelector("#sk-overflow-warning"); + if (warning) { + warning.remove(); + } +} + +/** + * Zeigt eine Validierungs-Fehlermeldung an + * @param {string} message - Fehlermeldung + * @param {HTMLElement} container - Container für die Meldung + */ +export function showValidationError(message, container) { + // Entferne vorherige Fehlermeldungen + hideValidationError(container); + hideOverflowWarning(container); + + const errorDiv = document.createElement("div"); + errorDiv.className = "sk-validation-error"; + errorDiv.id = "sk-validation-error"; + + errorDiv.innerHTML = ` +
    + Validierung nicht möglich +
    +

    ${escapeHtml(message)}

    +

    + Bitte laden Sie die Seite neu und versuchen Sie es erneut. +

    + `; + + // Am Anfang des Containers einfügen + container.insertBefore(errorDiv, container.firstChild); + + // Scroll zur Meldung + errorDiv.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +/** + * Versteckt die Validierungs-Fehlermeldung + */ +export function hideValidationError(container) { + const error = container?.querySelector("#sk-validation-error"); + if (error) { + error.remove(); + } +} + +/** + * HTML escaping für sichere Ausgabe + */ +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +// Preview-Generierung Funktionen +// Alte Preview-Funktionen entfernt - jetzt wird PreviewManager verwendet + +// Preview-Funktionen verwenden jetzt PreviewManager + +function readPersisted() { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + const data = JSON.parse(raw); + if (!data || typeof data !== "object") return null; + return data; + } catch { + return null; + } +} + +// Sofortige Speicherung (intern verwendet) +function writePersistedNow(state) { + try { + const productKey = state?.ctx?.product?.key || null; + // Auch ohne Produktauswahl speichern, wenn customerType gesetzt ist + if (!productKey && !state.answers.customerType) return; + + // Order-Daten OHNE AGB/Datenschutz speichern (müssen immer neu bestätigt werden) + const orderWithoutConsent = { ...(state.order || {}) }; + delete orderWithoutConsent.acceptedAgb; + delete orderWithoutConsent.acceptedPrivacy; + + const payload = { + productKey, + answers: state.answers || {}, + recipientRows: Array.isArray(state.recipientRows) + ? state.recipientRows + : [], + addressMode: state.addressMode || "classic", + freeAddressRows: Array.isArray(state.freeAddressRows) + ? state.freeAddressRows + : [], + placeholderValues: state.placeholderValues || {}, + order: orderWithoutConsent, + step: state.step || 0, + history: state.history || [], + }; + localStorage.setItem(LS_KEY, JSON.stringify(payload)); + } catch {} +} + +// Markiert State als dirty - Speicherung erfolgt periodisch oder bei Page Leave +function writePersisted(state) { + markPersistDirty(); +} + +function hydrateIfPossible(state, dispatch) { + // Nur EINMAL hydrieren - beim ersten Laden + if (hydratedForProductKey !== null) return false; + + // Flag SOFORT setzen um Mehrfachausführung zu verhindern + hydratedForProductKey = "done"; + + const productKey = state?.ctx?.product?.key || null; + const saved = readPersisted(); + + if (!saved) return false; + + // Wenn ein Produkt ausgewählt ist, nur hydrieren wenn es übereinstimmt + if (productKey && saved.productKey !== productKey) return false; + + // URL-Parameter haben Vorrang + const urlQuantity = state?.ctx?.quantity || null; + const urlFormat = state?.ctx?.format || null; + + // EINEN großen HYDRATE dispatch statt vieler kleiner + dispatch({ + type: "HYDRATE_ALL", + payload: { + productKey: !productKey && saved.productKey ? saved.productKey : null, + answers: saved.answers || {}, + recipientRows: saved.recipientRows || null, + addressMode: saved.addressMode || null, + freeAddressRows: saved.freeAddressRows || null, + placeholderValues: saved.placeholderValues || null, + order: saved.order || null, + step: saved.step, + urlQuantity, + urlFormat, + currentStep: state.step, + }, + }); + + return true; // Hydration wurde durchgeführt, neuer Render wurde ausgelöst +} + +/* DOM helper */ +function h(tag, attrs = {}, children = []) { + const el = document.createElement(tag); + + for (const [k, v] of Object.entries(attrs || {})) { + if (k === "class") { + el.className = v; + continue; + } + + if (k === "text") { + el.textContent = v; + continue; + } + + if (k.startsWith("on") && typeof v === "function") { + el.addEventListener(k.slice(2), v); + continue; + } + + if ( + k === "value" && + (tag === "input" || tag === "textarea" || tag === "select") + ) { + el.value = v ?? ""; + continue; + } + if (k === "checked" && tag === "input") { + el.checked = !!v; + continue; + } + if (k === "disabled") { + el.disabled = !!v; + continue; + } + if (k === "selected" && tag === "option") { + el.selected = !!v; + continue; + } + + if (v === true) el.setAttribute(k, ""); + else if (v !== false && v != null) el.setAttribute(k, String(v)); + } + + for (const c of children) + el.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + return el; +} + +function clear(el) { + if (!el) return; + while (el.firstChild) el.removeChild(el.firstChild); +} + +/* Fokus Erhalt */ +function captureFocus(dom) { + const a = document.activeElement; + if (!a || !dom.form || !dom.form.contains(a)) return null; + + const key = a.getAttribute("data-sk-focus"); + if (!key) return null; + + const start = typeof a.selectionStart === "number" ? a.selectionStart : null; + const end = typeof a.selectionEnd === "number" ? a.selectionEnd : null; + + // Scroll-Position des Textfelds speichern + const scrollTop = typeof a.scrollTop === "number" ? a.scrollTop : null; + const scrollLeft = typeof a.scrollLeft === "number" ? a.scrollLeft : null; + + return { key, start, end, scrollTop, scrollLeft }; +} + +function restoreFocus(dom, snapshot) { + if (!snapshot) return; + + const el = dom.form.querySelector(`[data-sk-focus="${snapshot.key}"]`); + if (!el) return; + + el.focus({ preventScroll: true }); + + if ( + snapshot.start != null && + snapshot.end != null && + typeof el.setSelectionRange === "function" + ) { + try { + el.setSelectionRange(snapshot.start, snapshot.end); + } catch {} + } + + // Scroll-Position des Textfelds wiederherstellen + if (snapshot.scrollTop != null) { + el.scrollTop = snapshot.scrollTop; + } + if (snapshot.scrollLeft != null) { + el.scrollLeft = snapshot.scrollLeft; + } +} + +/* Utils */ +// fmtEUR is now imported from configurator-pricing.js + +function stepTitle(step) { + switch (step) { + case STEPS.PRODUCT: + return "Produkt"; + case STEPS.QUANTITY: + return "Allgemein"; + case STEPS.ENVELOPE: + return "Umschlag"; + case STEPS.CONTENT: + return "Inhalt"; + case STEPS.CUSTOMER_DATA: + return "Kundendaten"; + case STEPS.REVIEW: + return "Prüfen"; + default: + return ""; + } +} + +/** + * Rendert einen Link zu den Schriftbeispielen (öffnet in neuem Tab) + */ +function renderFontSampleLink() { + const fontSampleUrl = + window.SkriftConfigurator?.settings?.font_sample?.url || ""; + if (!fontSampleUrl) return null; + + return h("div", { class: "sk-help", style: "margin-top: 8px;" }, [ + h("a", { + href: fontSampleUrl, + target: "_blank", + rel: "noopener noreferrer", + style: "color: #0073aa; text-decoration: underline;", + text: "Schriftbeispiele ansehen →", + }), + ]); +} + +/** + * Rendert Platzhalter-Hilfetext mit optionalem Link zur Hilfeseite + * @param {string[]} hints - Zusätzliche Hinweise (optional) + */ +function renderPlaceholderHelpText(hints = []) { + const helpUrl = + window.SkriftConfigurator?.settings?.font_sample?.placeholder_help_url || + ""; + + const elements = [ + document.createTextNode( + "Sie können Platzhalter verwenden, um dynamische Inhalte einzufügen. Platzhalter werden in doppelten eckigen Klammern geschrieben (z.B. ", + ), + h("code", { text: "[[vorname]]" }), + document.createTextNode(" oder "), + h("code", { text: "[[firma]]" }), + document.createTextNode( + "). Diese werden später durch die tatsächlichen Werte ersetzt.", + ), + ]; + + // Hints hinzufügen + if (hints.length > 0) { + elements.push(document.createTextNode(" | " + hints.join(" | "))); + } + + // Link hinzufügen + if (helpUrl) { + elements.push(document.createTextNode(" ")); + elements.push( + h("a", { + href: helpUrl, + target: "_blank", + rel: "noopener noreferrer", + style: "color: #0073aa; text-decoration: underline;", + text: "Mehr erfahren →", + }), + ); + } + + return h("div", { class: "sk-help" }, elements); +} + +function renderCard(title, subtitle, bodyNodes = [], footerNodes = []) { + const card = h("div", { class: "sk-card" }, []); + + if (title) { + const head = h("div", { class: "sk-card-head" }, [ + h("h3", { class: "sk-card-title", text: title }), + ]); + if (subtitle) { + head.appendChild(h("div", { class: "sk-card-subtitle", text: subtitle })); + } + card.appendChild(head); + } + + card.appendChild(h("div", { class: "sk-card-body" }, bodyNodes)); + + if (footerNodes && footerNodes.length) { + card.appendChild(h("div", { class: "sk-card-foot" }, footerNodes)); + } + + return card; +} + +function renderStepper(stepperEl, state, dispatch) { + clear(stepperEl); + + const steps = [ + { id: STEPS.PRODUCT, label: "Produkt", icon: "📦" }, + { id: STEPS.QUANTITY, label: "Allgemein", icon: "⚙️" }, + { id: STEPS.ENVELOPE, label: "Umschlag", icon: "✉️" }, + { id: STEPS.CONTENT, label: "Inhalt", icon: "✍️" }, + { id: STEPS.CUSTOMER_DATA, label: "Kundendaten", icon: "📋" }, + { id: STEPS.REVIEW, label: "Prüfen", icon: "✓" }, + ]; + + const stepperContent = h("div", { class: "sk-stepper" }, []); + + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + const isActive = state.step === s.id; + const isComplete = s.id < state.step; + const canJumpBack = s.id <= state.step; + + // Button Content: Häkchen-Icon für erledigte Steps + const btnChildren = []; + if (isComplete) { + btnChildren.push(h("span", { class: "sk-chip-check", text: "✓" })); + } + btnChildren.push(s.label); + + const btn = h( + "button", + { + type: "button", + class: `sk-chip ${isActive ? "is-active" : ""} ${ + isComplete ? "is-complete" : "" + }`, + disabled: !canJumpBack, + onclick: () => dispatch({ type: "SET_STEP", step: s.id }), + }, + btnChildren, + ); + stepperContent.appendChild(btn); + + // Pfeil nach jedem Step außer dem letzten (dünner Chevron-Pfeil) + if (i < steps.length - 1) { + stepperContent.appendChild( + h("span", { class: "sk-stepper-arrow", text: "›" }), + ); + } + } + + // Stepper in Card integrieren (ohne Titel) + const card = renderCard(null, null, [stepperContent]); + stepperEl.appendChild(card); + + // Mobile: Auto-Scroll zum aktiven Step + requestAnimationFrame(() => { + const activeChip = stepperContent.querySelector(".sk-chip.is-active"); + if (activeChip && stepperContent) { + // Prüfe ob wir auf einem mobilen Gerät sind (stepper ist horizontal scrollbar) + const isMobile = window.innerWidth <= 640; + + if (isMobile) { + // Berechne die Scroll-Position um den aktiven Chip zu zentrieren + const containerWidth = stepperContent.offsetWidth; + const chipLeft = activeChip.offsetLeft; + const chipWidth = activeChip.offsetWidth; + const scrollLeft = chipLeft - containerWidth / 2 + chipWidth / 2; + + stepperContent.scrollTo({ + left: Math.max(0, scrollLeft), + behavior: "smooth", + }); + } else { + // Desktop: Standard scrollIntoView + activeChip.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + } + }); +} + +function renderTopbar(dom, state) { + clear(dom.topbar); + + const q = state.quote || {}; + const isB2B = state.answers?.customerType === "business"; + const isFU = isFollowups(state); + + let priceText = "bitte Produkt wählen"; + let priceNote = ""; + + if (isFU) { + // Follow-ups: Zeige "Ab-Preis" OHNE Multiplikator und OHNE Einmalkosten (nur pro Stück) + const basePrice = calculateFollowupBasePricePerPiece(state); + if (basePrice > 0) { + const formatted = formatPrice(basePrice, state); + priceText = "ab " + formatted.display; + priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; + } else { + priceText = "ab auf Anfrage"; + priceNote = ""; + } + } else if (state.answers.quantity > 0 && state.ctx?.product) { + // Standard-Produkte: Zeige "ab" + Preis pro Stück + const product = state.ctx.product; + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const taxRate = (prices.tax_rate || 19) / 100; + + // Im Product- und Quantity-Step: Nur dynamischer Mengenpreis (Basispreis × Multiplikator) + // Keine zusätzlichen Kosten (A4, Motiv, Versand) einberechnen + const isEarlyStep = + state.step === STEPS.PRODUCT || state.step === STEPS.QUANTITY; + + if (isEarlyStep) { + // Nur Basispreis × dynamische Mengenformel + const basePrice = product.basePrice || 0; + const dynamicPricing = + window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + const formula = isB2B + ? dynamicPricing.business_formula || "1" + : dynamicPricing.private_formula || "1"; + + const qty = Number(state.answers.quantity) || 1; + const normalQty = isB2B + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50; + const minQty = isB2B + ? dynamicPricing.business_min_quantity || 50 + : dynamicPricing.private_min_quantity || 10; + + // Formel auswerten + let multiplier = 1; + try { + const formulaWithValues = formula + .replace(/%qty%/g, qty) + .replace(/%norm_b%/g, dynamicPricing.business_normal_quantity || 200) + .replace(/%mind_b%/g, dynamicPricing.business_min_quantity || 50) + .replace(/%norm_p%/g, dynamicPricing.private_normal_quantity || 50) + .replace(/%mind_p%/g, dynamicPricing.private_min_quantity || 10); + multiplier = eval(formulaWithValues); + if (isNaN(multiplier) || multiplier < 1) multiplier = 1; + } catch (e) { + multiplier = 1; + } + + const priceNet = basePrice * multiplier; + const priceGross = priceNet * (1 + taxRate); + const displayPrice = isB2B + ? Math.round(priceNet * 100) / 100 + : Math.round(priceGross * 100) / 100; + + priceText = "ab " + fmtEUR(displayPrice); + priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; + } else { + // Ab Envelope-Step: Vollständige Preisberechnung inkl. aller Optionen + // calculatePricePerPiece verwendet bereits immer den Inlandspreis + const pricePerPiece = calculatePricePerPiece(state); + + if (pricePerPiece > 0) { + const formatted = formatPrice(pricePerPiece, state); + priceText = "ab " + formatted.display; + priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; + } + } + } + + const priceSection = h("div", { class: "sk-price" }, [ + h("div", { class: "sk-price-label", text: "Ihr Preis" }), + h("div", { + class: "sk-price-value", + text: priceText, + }), + h("div", { class: "sk-price-note", text: priceNote }), + ]); + + const productInfo = h("div", { class: "sk-product-info" }, []); + + if (state?.ctx?.product) { + productInfo.appendChild( + h("div", { class: "sk-product-icon", text: state.ctx.product.label[0] }), + ); + productInfo.appendChild( + h("div", {}, [ + h("div", { class: "sk-product-label", text: state.ctx.product.label }), + h("div", { + class: "sk-text-small sk-text-muted", + text: stepTitle(state.step), + }), + ]), + ); + } + + dom.topbar.appendChild(priceSection); + dom.topbar.appendChild(productInfo); +} + +/* STEP RENDERERS */ + +// Step 1: Produkt (Kundentyp + Produktauswahl kombiniert) +function renderProductStep(state, dispatch) { + const blocks = []; + + // Kundentyp Auswahl + const customerOptions = [ + { + value: "business", + label: "Geschäftskunde", + desc: "Für Unternehmen, Selbstständige und gewerbliche Kunden", + icon: "🏢", + }, + { + value: "private", + label: "Privatkunde", + desc: "Für private Anlässe und persönliche Korrespondenz", + icon: "🏠", + }, + ]; + + const customerBody = customerOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.customerType === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "SET_CUSTOMER_TYPE", customerType: opt.value }), + }, + [ + h("input", { + type: "radio", + name: "customerType", + checked: state.answers.customerType === opt.value, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { + class: "sk-option-label", + text: `${opt.icon} ${opt.label}`, + }), + h("div", { class: "sk-option-desc", text: opt.desc }), + ]), + ], + ), + ); + + blocks.push( + renderCard( + "Als was möchten Sie einkaufen?", + "Wählen Sie Ihren Kundentyp aus", + [h("div", { class: "sk-options" }, customerBody)], + ), + ); + + // Produktauswahl (nur wenn Kundentyp gewählt) + if (state.answers.customerType) { + const products = getAvailableProductsForCustomerType( + state.answers.customerType, + ); + + const grid = h("div", { class: "sk-selection-grid" }, []); + for (const p of products) { + const isSelected = state?.ctx?.product?.key === p.key; + + // Preisberechnung für Anzeige: Nur Basispreis (ohne Versand, Optionen etc.) + let displayPrice = p.basePrice || 2.5; + + // MwSt. aufschlagen (nur für Privatkunden) + const isBusinessCustomer = state.answers?.customerType === "business"; + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const taxRate = (prices.tax_rate || 19) / 100; + + // Business: Nettopreis, Privat: Bruttopreis + const finalPrice = isBusinessCustomer + ? displayPrice + : displayPrice * (1 + taxRate); + + const priceText = `ab ${fmtEUR(finalPrice)}`; + const descText = + p.description || "Professionelle handgeschriebene Korrespondenz"; + + const card = h( + "div", + { + class: `sk-selection-card ${isSelected ? "is-selected" : ""}`, + onclick: () => dispatch({ type: "SET_PRODUCT", product: p }), + }, + [ + h("div", { class: "sk-selection-card-image" }, [ + h("div", { text: "📄", style: "font-size: 48px" }), // Placeholder + ]), + h("div", { class: "sk-selection-card-content" }, [ + h("div", { class: "sk-selection-card-title", text: p.label }), + h("div", { class: "sk-selection-card-price", text: priceText }), + h("div", { + class: "sk-selection-card-desc", + text: descText, + }), + ]), + ], + ); + grid.appendChild(card); + } + + blocks.push( + renderCard( + "Welches Produkt benötigen Sie?", + "Wählen Sie das passende Produkt für Ihren Bedarf", + [grid], + ), + ); + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Step 3: Menge & Format +function renderQuantityStep(state, dispatch) { + const blocks = []; + + // Mengenabfrage (für normale Produkte) + if (!isFollowups(state)) { + // Mindestmenge ermitteln (bei noLimits = 1) + const dynamicPricing = + window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + const isB2B = state.answers?.customerType === "business"; + const noLimits = state.noLimits || false; + + const minQuantity = noLimits + ? 1 + : isB2B + ? dynamicPricing.business_min_quantity || 50 + : dynamicPricing.private_min_quantity || 10; + + const normalQuantity = isB2B + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50; + + // Standardmenge auf beste Preismenge setzen (normalQuantity statt minQuantity) + const currentQty = state.answers.quantity || normalQuantity; + const isBelow = currentQty < minQuantity; + + // Erstelle Input-Element mit manuellem Value-Management + const inputEl = h("input", { + class: `sk-input ${isBelow ? "sk-input-error" : ""}`, + type: "number", + min: String(minQuantity), + "data-sk-focus": "quantity.amount", + onchange: (e) => { + // Update state nur bei change (Enter oder Blur) + const val = + e.target.value === "" ? minQuantity : Number(e.target.value); + const finalVal = isNaN(val) || val < minQuantity ? minQuantity : val; + e.target.value = String(finalVal); + // Nur dispatchen wenn sich der Wert geändert hat + if (finalVal !== state.answers.quantity) { + dispatch({ + type: "ANSWER", + patch: { quantity: finalVal }, + }); + } + }, + }); + + // Setze initialen Wert manuell + inputEl.value = String(currentQty); + + const quantityInput = [inputEl]; + + // Fehlermeldung bei Unterschreitung + if (isBelow) { + quantityInput.push( + h("div", { + class: "sk-error-message", + style: "color: #d32f2f; margin-top: 8px; font-size: 14px;", + text: `Mindestmenge: ${minQuantity} Stück`, + }), + ); + } + + // Beschreibungstext je nach noLimits-Modus + const quantityDescription = noLimits + ? `Unser bester Preis gilt ab ${normalQuantity} Stück` + : `Mindestmenge: ${minQuantity} Stück • Unser bester Preis gilt ab ${normalQuantity} Stück`; + + blocks.push( + renderCard( + "Wie viele Schriftstücke benötigen Sie?", + quantityDescription, + quantityInput, + ), + ); + } + + // Follow-ups: Jahresvolumen + if (isFollowups(state)) { + const volumes = [ + { value: "5-49", label: "5-49 Follow-ups pro Monat" }, + { value: "50-199", label: "50-199 Follow-ups pro Monat" }, + { value: "200-499", label: "200-499 Follow-ups pro Monat" }, + { value: "500-999", label: "500-999 Follow-ups pro Monat" }, + { value: "1000+", label: "1000+ Follow-ups pro Monat" }, + ]; + + const volBody = volumes.map((vol) => + h( + "div", + { + class: `sk-option ${ + state.answers.followupYearlyVolume === vol.value + ? "is-selected" + : "" + }`, + onclick: () => + dispatch({ + type: "ANSWER", + patch: { followupYearlyVolume: vol.value }, + }), + }, + [ + h("input", { + type: "radio", + name: "volume", + checked: state.answers.followupYearlyVolume === vol.value, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label", text: vol.label }), + ]), + ], + ), + ); + + blocks.push( + renderCard( + "Um wie viele Follow-ups geht es ca. pro Monat?", + "Dies hilft uns bei der Preiskalkulation", + [h("div", { class: "sk-options" }, volBody)], + ), + ); + } + + // Format Auswahl + const formats = state?.ctx?.product?.formats || []; + if (formats.length > 1) { + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const a4Surcharge = prices.a4_upgrade_surcharge || 0; + const product = state?.ctx?.product; + + // B2C Brutto-Preis Hilfsfunktion + const isB2B = state.answers?.customerType === "business"; + const taxRate = (prices.tax_rate || 19) / 100; + const displayPrice = (nettoPrice) => + isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + + // Prüfen ob A4 Aufpreis gilt (nur für Einladungen und Follow-ups) + const hasA4Surcharge = + (product?.key === "einladungen" || product?.key === "follow-ups") && + formats.includes("a4"); + + const formatMap = { + a4: { + label: "A4", + desc: "Standardformat für Briefe", + price: hasA4Surcharge ? `+ ${fmtEUR(displayPrice(a4Surcharge))}` : "", + }, + a6p: { + label: "A6 Hochformat", + desc: "Kompakt, ideal für Postkarten", + price: "", + }, + a6l: { + label: "A6 Querformat", + desc: "Kompakt, ideal für Postkarten", + price: "", + }, + }; + + const formatBody = formats.map((f) => { + const info = formatMap[f] || { label: f, desc: "", price: "" }; + return h( + "div", + { + class: `sk-option ${state.answers.format === f ? "is-selected" : ""}`, + onclick: () => dispatch({ type: "ANSWER", patch: { format: f } }), + }, + [ + h("input", { + type: "radio", + name: "format", + checked: state.answers.format === f, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: info.label }), + h("div", { class: "sk-option-desc", text: info.desc }), + info.price + ? h("div", { class: "sk-option-price", text: info.price }) + : null, + ].filter(Boolean), + ), + ], + ); + }); + + blocks.push( + renderCard("Welches Format soll das Schriftstück haben?", null, [ + h("div", { class: "sk-options" }, formatBody), + ]), + ); + } else if (formats.length === 1) { + // Auto-set single format (nur wenn noch nicht gesetzt) + if (!state.answers.format) { + dispatch({ type: "ANSWER", patch: { format: formats[0] } }); + } + } + + // Schriftart wird jetzt in Umschlag und Inhalt Steps separat gewählt (nicht hier) + // Default auf tilda wenn noch nicht gesetzt + if (!state.answers.font) { + dispatch({ type: "ANSWER", patch: { font: "tilda" } }); + } + if (!state.answers.envelopeFont) { + dispatch({ type: "ANSWER", patch: { envelopeFont: "tilda" } }); + } + + // Follow-ups: Erstellungsmodus + if (isFollowups(state)) { + // API-Preis aus Backend holen + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const apiPrice = prices.api_connection || 250; + + // B2C Brutto-Preis Hilfsfunktion + const isB2B = state.answers?.customerType === "business"; + const taxRate = (prices.tax_rate || 19) / 100; + const displayPrice = (nettoPrice) => + isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + + const createModes = [ + { + value: "auto", + label: "Automatisch aus System", + desc: "Wir verbinden uns mit Ihrem CRM/Shop-System", + price: `Einmalig ${fmtEUR(displayPrice(apiPrice))}`, + }, + { + value: "manual", + label: "Manuell", + desc: "Sie senden uns die Empfängerliste im gewählten Rhythmus", + }, + ]; + + const createBody = createModes.map((mode) => + h( + "div", + { + class: `sk-option ${ + state.answers.followupCreateMode === mode.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ + type: "ANSWER", + patch: { followupCreateMode: mode.value }, + }), + }, + [ + h("input", { + type: "radio", + name: "createMode", + checked: state.answers.followupCreateMode === mode.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: mode.label }), + h("div", { class: "sk-option-desc", text: mode.desc }), + mode.price + ? h("div", { class: "sk-option-price", text: mode.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Wie sollen die Follow-ups erstellt werden?", null, [ + h("div", { class: "sk-options" }, createBody), + ]), + ); + + // Automatische Erstellung Details + if (state.answers.followupCreateMode === "auto") { + blocks.push( + renderCard("Details zur Systemanbindung", null, [ + h("div", { class: "sk-field" }, [ + h("label", { + class: "sk-field-label is-required", + text: "Aus welchem System?", + }), + h("input", { + class: "sk-input", + type: "text", + value: state.answers.followupSourceSystem || "", + "data-sk-focus": "followup.system", + placeholder: "z.B. Shopify, WooCommerce, Sevdesk, HubSpot", + oninput: (e) => + dispatch({ + type: "ANSWER", + patch: { followupSourceSystem: e.target.value }, + }), + }), + ]), + h("div", { class: "sk-field" }, [ + h("label", { + class: "sk-field-label is-required", + text: "Was soll die Erstellung eines Follow-ups auslösen?", + }), + h("textarea", { + class: "sk-textarea", + rows: "3", + value: state.answers.followupTriggerDescription || "", + "data-sk-focus": "followup.trigger", + placeholder: + "z.B. Wenn ein neuer Kundenauftrag eingeht oder der Kunde seit 3 Monaten nichts mehr bestellt hat.", + oninput: (e) => + dispatch({ + type: "ANSWER", + patch: { followupTriggerDescription: e.target.value }, + }), + }), + ]), + h("div", { class: "sk-field" }, [ + h("label", { class: "sk-field-label", text: "Prüfrhythmus" }), + h( + "select", + { + class: "sk-select", + value: state.answers.followupCheckCycle || "monthly", + onchange: (e) => + dispatch({ + type: "ANSWER", + patch: { followupCheckCycle: e.target.value }, + }), + }, + [ + h("option", { value: "monthly" }, ["Monatlich"]), + h("option", { value: "2m" }, ["Alle 2 Monate"]), + h("option", { value: "3m" }, ["Alle 3 Monate"]), + h("option", { value: "6m" }, ["Alle 6 Monate"]), + h("option", { value: "yearly" }, ["Jährlich"]), + ], + ), + ]), + ]), + ); + } + + // Info-Nachricht für Follow-ups (wird immer angezeigt) + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Nach Ihrer Bestellung werden wir uns mit Ihnen in Verbindung setzen, um alle Details zu besprechen und die optimale Lösung für Ihre Follow-ups zu finden.", + ]), + ); + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Step 3: Umschlag (Versand + Umschlag kombiniert) +function renderEnvelopeStep(state, dispatch) { + const blocks = []; + + // Schriftart-Auswahl für Umschlag (ERSTE FRAGE - Dropdown) + blocks.push( + renderCard("Schriftart für Umschlag", null, [ + h( + "div", + { class: "sk-field" }, + [ + h( + "select", + { + class: "sk-select", + value: state.answers.envelopeFont || "tilda", + onchange: (e) => + dispatch({ + type: "ANSWER", + patch: { envelopeFont: e.target.value }, + }), + }, + [ + h("option", { + value: "tilda", + text: "Tilda - Druckschrift, leicht lesbar und steril", + selected: (state.answers.envelopeFont || "tilda") === "tilda", + }), + h("option", { + value: "alva", + text: "Alva - Druckschrift, organisch und informell", + selected: state.answers.envelopeFont === "alva", + }), + h("option", { + value: "ellie", + text: "Ellie - Schreibschrift, elegant und geschwungen", + selected: state.answers.envelopeFont === "ellie", + }), + ], + ), + renderFontSampleLink(), + ].filter(Boolean), + ), + ]), + ); + + // Preise aus Backend holen + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const shippingBulk = prices.shipping_bulk || 4.95; + + // B2C Brutto-Preis Hilfsfunktion + const isB2B = state.answers?.customerType === "business"; + const taxRate = (prices.tax_rate || 19) / 100; + const displayPrice = (nettoPrice) => + isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + + // Direktversand-Preise berechnen (Inland/Ausland) + const shippingPrices = calculateDirectShippingPrices(state); + const domesticPriceText = fmtEUR(displayPrice(shippingPrices.domesticPrice)); + const internationalPriceText = fmtEUR( + displayPrice(shippingPrices.internationalPrice), + ); + + // Versandart Auswahl + // Bei Follow-ups ist nur Direktversand möglich und kein Aufpreis wird angezeigt + const shippingOptions = isFollowups(state) + ? [ + { + value: "direct", + label: "Einzeln an die Empfänger", + desc: "Direktversand an Ihre Empfänger – inklusive Kuvertierung und Beschriftung.", + price: "", // Kein Aufpreis bei Follow-ups anzeigen + }, + ] + : [ + { + value: "direct", + label: "Einzeln an die Empfänger", + desc: "Wir versenden direkt an Ihre Empfänger – inklusive Kuvertierung und Beschriftung", + price: `Inland: ${domesticPriceText} / Ausland: ${internationalPriceText} pro Stück`, + }, + { + value: "bulk", + label: "Sammelversand an Sie", + desc: "Sie erhalten alle Schriftstücke zur eigenen Verteilung", + price: `Einmalig ${fmtEUR(displayPrice(shippingBulk))} Versandkosten`, + }, + ]; + + const shippingBody = shippingOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.shippingMode === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { shippingMode: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "shipping", + checked: state.answers.shippingMode === opt.value, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label", text: opt.label }), + h("div", { class: "sk-option-desc", text: opt.desc }), + h("div", { class: "sk-option-price", text: opt.price }), + ]), + ], + ), + ); + + blocks.push( + renderCard( + "Wie sollen die Schriftstücke versendet werden?", + "Wählen Sie die passende Versandart", + [h("div", { class: "sk-options" }, shippingBody)], + ), + ); + + // Umschlag-Optionen (nur wenn Versandart gewählt) + if (!state.answers.shippingMode) { + return h("div", { class: "sk-stack" }, blocks); + } + + // Bei Direktversand automatisch Umschlag + if (state.answers.shippingMode === "direct") { + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Bei Direktversand an Empfänger wird automatisch ein beschrifteter Umschlag verwendet.", + ]), + ); + } else { + // Bulk: Umschlag optional + const envelopeBase = prices.envelope_base || 0.5; + const envOptions = [ + { + value: true, + label: "Ja, ich wünsche ein Kuvert", + price: `+ ${fmtEUR(displayPrice(envelopeBase))}`, + }, + { value: false, label: "Nein, ich benötige kein Kuvert", price: "" }, + ]; + + const envBody = envOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.envelope === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { envelope: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "envelope", + checked: state.answers.envelope === opt.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: opt.label }), + opt.price + ? h("div", { class: "sk-option-price", text: opt.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Benötigen Sie ein Kuvert?", null, [ + h("div", { class: "sk-options" }, envBody), + ]), + ); + } + + // Beschriftungsmodus + // Bei Direktversand: Direkt Empfängerdaten-Tabelle zeigen, keine Auswahl + if (state.answers.shippingMode === "direct") { + // envelopeMode wird automatisch durch den Reducer auf "recipientData" gesetzt + // wenn shippingMode === "direct" (siehe configurator-state.js Zeile 486-492) + + // Bei Follow-ups: Unterscheidung zwischen auto und manual + const isFollowupsProduct = state?.ctx?.product?.isFollowUp === true; + if (!isFollowupsProduct) { + // Normale Produkte: Empfängerdaten-Tabelle anzeigen + blocks.push(renderRecipientsTable(state, dispatch)); + } else if (state.answers.followupCreateMode === "auto") { + // Follow-ups mit automatischer Erstellung: Daten kommen aus System + blocks.push( + renderCard("Empfängerdaten", null, [ + h("div", { class: "sk-alert sk-alert-info" }, [ + "Die Empfängerdaten werden automatisch aus Ihrem System übernommen. " + + "Im Rahmen der Einrichtung werden wir gemeinsam die Schnittstelle zu Ihrem System konfigurieren.", + ]), + ]), + ); + } else { + // Follow-ups mit manueller Erstellung: Daten werden später übermittelt + blocks.push( + renderCard("Empfängerdaten", null, [ + h("div", { class: "sk-alert sk-alert-info" }, [ + "Im Nachgang wird Ihnen eine E-Mail zugesendet mit weiteren Angaben, " + + "wohin Sie die Empfängerdaten hochladen können.", + ]), + ]), + ); + } + } else if (state.answers.envelope === true) { + // Bei Bulk-Versand mit Umschlag: Beschriftungsmodus-Auswahl anzeigen + // Neues Feld: envelope_labeling, Fallback auf alte Felder + const envelopeLabeling = + prices.envelope_labeling || prices.envelope_recipient_address || 0.5; + + const modeOptions = [ + { + value: "recipientData", + label: "Empfängerdaten", + desc: "Klassische Adressierung mit Name und Anschrift", + price: `+ ${fmtEUR(displayPrice(envelopeLabeling))}`, + }, + { + value: "customText", + label: "Individueller Text", + desc: "Freier Text mit Platzhaltern", + price: `+ ${fmtEUR(displayPrice(envelopeLabeling))}`, + }, + { + value: "none", + label: "Keine Beschriftung", + desc: "Umschlag bleibt unbeschriftet", + price: "", + }, + ]; + + const modeBody = modeOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.envelopeMode === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { envelopeMode: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "envelopeMode", + checked: state.answers.envelopeMode === opt.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: opt.label }), + h("div", { class: "sk-option-desc", text: opt.desc }), + opt.price + ? h("div", { class: "sk-option-price", text: opt.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Womit soll das Kuvert beschriftet werden?", null, [ + h("div", { class: "sk-options" }, modeBody), + ]), + ); + + // Empfängerdaten Tabelle (nur wenn gewählt) + if (state.answers.envelopeMode === "recipientData") { + blocks.push(renderRecipientsTable(state, dispatch)); + } + + // Custom Text + if (state.answers.envelopeMode === "customText") { + blocks.push( + renderCard( + "Text für Umschlag", + "Max. 160 Zeichen, keine Zeilenumbrüche", + [ + h("input", { + type: "text", + class: "sk-input", + maxlength: "160", + value: state.answers.envelopeCustomText || "", + "data-sk-focus": "envelope.customText", + placeholder: "z.B. Herzliche Grüße an [[vorname]] [[nachname]]", + oninput: (() => { + let debounceTimer = null; + return (e) => { + // Zeilenumbrüche entfernen (falls durch Paste eingefügt) + const value = e.target.value.replace(/[\r\n]/g, " "); + e.target.value = value; + // Debounced dispatch (300ms) + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + dispatch({ + type: "ANSWER", + patch: { envelopeCustomText: value }, + }); + }, 300); + }; + })(), + }), + renderPlaceholderHelpText(), + ], + ), + ); + + const ph = extractPlaceholders(state.answers.envelopeCustomText); + if (ph.length > 0) { + blocks.push( + renderPlaceholderTable(state, dispatch, ph, "Platzhalter-Werte"), + ); + } + } + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Step 6: Inhalt +function renderContentStep(state, dispatch) { + const blocks = []; + + // Schriftart-Auswahl für Schriftstück (ERSTE FRAGE - Dropdown) + blocks.push( + renderCard("Schriftart für Schriftstück", null, [ + h( + "div", + { class: "sk-field" }, + [ + h( + "select", + { + class: "sk-select", + value: state.answers.font || "tilda", + onchange: (e) => + dispatch({ + type: "ANSWER", + patch: { font: e.target.value }, + }), + }, + [ + h("option", { + value: "tilda", + text: "Tilda - Druckschrift, leicht lesbar und steril", + selected: (state.answers.font || "tilda") === "tilda", + }), + h("option", { + value: "alva", + text: "Alva - Druckschrift, organisch und informell", + selected: state.answers.font === "alva", + }), + h("option", { + value: "ellie", + text: "Ellie - Schreibschrift, elegant und geschwungen", + selected: state.answers.font === "ellie", + }), + ], + ), + renderFontSampleLink(), + ].filter(Boolean), + ), + ]), + ); + + // Texterstellung + // Preise aus Backend holen + const prices = window.SkriftConfigurator?.settings?.prices || {}; + const textservicePrice = prices.textservice || 0; + + // B2C Brutto-Preis Hilfsfunktion + const isB2B = state.answers?.customerType === "business"; + const taxRate = (prices.tax_rate || 19) / 100; + const displayPrice = (nettoPrice) => + isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + + const contentOptions = [ + { + value: "self", + label: "Ich erstelle den Text selbst", + desc: "Sie geben den vollständigen Text vor", + }, + { + value: "textservice", + label: "Textservice beauftragen", + desc: "Wir erstellen den Text professionell für Sie", + price: + textservicePrice > 0 + ? `Einmalig ${fmtEUR(displayPrice(textservicePrice))}` + : "", + }, + ]; + + const contentBody = contentOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.contentCreateMode === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { contentCreateMode: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "contentMode", + checked: state.answers.contentCreateMode === opt.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: opt.label }), + h("div", { class: "sk-option-desc", text: opt.desc }), + opt.price + ? h("div", { class: "sk-option-price", text: opt.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Möchten Sie den Inhalt selbst erstellen?", null, [ + h("div", { class: "sk-options" }, contentBody), + ]), + ); + + // Textfeld (wenn selbst erstellt) + if (state.answers.contentCreateMode === "self") { + const maxChars = state.answers.format === "a4" ? 2500 : 500; + const hints = []; + + if (state.answers.envelopeMode === "recipientData") { + hints.push("Verfügbare Platzhalter: [[vorname]], [[name]]"); + } else { + const phEnv = state.placeholders?.envelope || []; + if (phEnv.length > 0) { + hints.push( + `Umschlag-Platzhalter: ${phEnv.map((p) => `[[${p}]]`).join(", ")}`, + ); + } + } + + blocks.push( + renderCard( + "Inhalt des Schriftstücks", + `Max. ${maxChars} Zeichen`, + [ + h("textarea", { + class: "sk-textarea", + rows: "10", + maxlength: String(maxChars), + value: state.answers.letterText || "", + "data-sk-focus": "content.text", + placeholder: + "Ihr Text hier. Nutzen Sie [[Platzhalter]] für individuelle Inhalte.", + oninput: (() => { + let debounceTimer = null; + return (e) => { + const value = e.target.value; + // Debounced dispatch (300ms) + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + dispatch({ + type: "ANSWER", + patch: { letterText: value }, + }); + }, 300); + }; + })(), + }), + renderPlaceholderHelpText(hints), + ].filter(Boolean), + ), + ); + + // Platzhalter Verarbeitung + const usedInLetter = extractPlaceholders(state.answers.letterText); + const envPlaceholders = state.placeholders?.envelope || []; + const envSet = new Set(envPlaceholders); + + // Bei Follow-ups: Keine Platzhalter-Tabelle anzeigen + // (Empfängerdaten kommen aus CRM, Platzhalter werden dort verwaltet) + if (isFollowups(state)) { + // Platzhalter-Tabelle für Follow-ups ausgeblendet + } else { + // Prüfen ob Empfängerdaten für Platzhalter verfügbar sind: + // - envelopeMode muss "recipientData" sein + // - addressMode muss "classic" sein (nicht "free") + // - envelope darf nicht false sein (kein Kuvert) + const hasClassicRecipientData = + state.answers.envelopeMode === "recipientData" && + (state.addressMode || "classic") === "classic" && + state.answers.envelope !== false; + + if (hasClassicRecipientData) { + // Bei klassischen Empfängerdaten: vorname, name, ort sind verfügbar + neue eigene Platzhalter + const predefinedRecipient = new Set(["vorname", "name", "ort"]); + const newPlaceholders = usedInLetter.filter( + (p) => !predefinedRecipient.has(p), + ); + + // Kombinierte Tabelle für reguläre Produkte + blocks.push( + renderCombinedPlaceholderTable( + state, + dispatch, + Array.from(predefinedRecipient), + newPlaceholders, + "Platzhalter-Werte", + ), + ); + } else if (state.answers.envelopeMode === "customText") { + // Bei individuellem Text: Alle Umschlag-Platzhalter verfügbar + neue eigene Platzhalter + const newPlaceholders = usedInLetter.filter((p) => !envSet.has(p)); + + // Kombinierte Tabelle: Umschlag-Platzhalter (read-only) + neue (editierbar) + if (envPlaceholders.length > 0 || newPlaceholders.length > 0) { + blocks.push( + renderCombinedPlaceholderTable( + state, + dispatch, + envPlaceholders, + newPlaceholders, + "Platzhalter-Werte", + ), + ); + } + } else { + // Kein Kuvert oder freie Adresse: Nur eigene Platzhalter anzeigen (ohne vorname, name, ort) + if (usedInLetter.length > 0) { + blocks.push( + renderPlaceholderTable( + state, + dispatch, + usedInLetter, + "Platzhalter-Werte", + false, + ), + ); + } + } + } + } else if (state.answers.contentCreateMode === "textservice") { + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Unser Textservice erstellt professionelle, individuell auf Ihre Bedürfnisse zugeschnittene Texte. " + + "Nach der Bestellung werden wir Sie kontaktieren, um Details zu besprechen.", + ]), + ); + } + + // Motiv (nur bei Postkarten, oder bei Einladungen/Follow-ups wenn A6 Format) + if (supportsMotif(state)) { + const motifOptions = [ + { value: true, label: "Ja, ich möchte ein Motiv" }, + { value: false, label: "Nein, kein Motiv" }, + ]; + + const motifBody = motifOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.motifNeed === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { motifNeed: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "motif", + checked: state.answers.motifNeed === opt.value, + onchange: () => {}, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label", text: opt.label }), + ]), + ], + ), + ); + + blocks.push( + renderCard( + "Soll ein Motiv auf der Vorderseite abgebildet werden?", + null, + [h("div", { class: "sk-options" }, motifBody)], + ), + ); + + // Motiv Quelle + if (state.answers.motifNeed === true) { + const motifUploadPrice = prices.motif_upload || 0.3; + const motifDesignPrice = prices.motif_design || 0; + + // Bei A4-Format ist Upload nicht möglich, nur bedruckte Karten + const isA4 = state.answers.format === "a4"; + + const sourceOptions = [ + !isA4 && { + value: "upload", + label: "Eigenes Motiv hochladen", + desc: "Sie laden Ihre Datei hoch", + price: + motifUploadPrice > 0 + ? `Einmalig ${fmtEUR(displayPrice(motifUploadPrice))}` + : "", + }, + { + value: "printed", + label: "Bedruckte Karten zusenden", + desc: isA4 + ? "Sie senden uns fertig bedruckte A4-Karten" + : "Sie senden uns fertig bedruckte A6-Karten", + price: "", + }, + !isA4 && { + value: "design", + label: "Designservice beauftragen", + desc: "Wir erstellen ein individuelles Design", + price: + motifDesignPrice > 0 + ? `Einmalig ${fmtEUR(displayPrice(motifDesignPrice))}` + : "", + }, + ].filter(Boolean); + + const sourceBody = sourceOptions.map((opt) => + h( + "div", + { + class: `sk-option ${ + state.answers.motifSource === opt.value ? "is-selected" : "" + }`, + onclick: () => + dispatch({ type: "ANSWER", patch: { motifSource: opt.value } }), + }, + [ + h("input", { + type: "radio", + name: "motifSource", + checked: state.answers.motifSource === opt.value, + onchange: () => {}, + }), + h( + "div", + { class: "sk-option-content" }, + [ + h("div", { class: "sk-option-label", text: opt.label }), + h("div", { class: "sk-option-desc", text: opt.desc }), + opt.price + ? h("div", { class: "sk-option-price", text: opt.price }) + : null, + ].filter(Boolean), + ), + ], + ), + ); + + blocks.push( + renderCard("Woher kommt das Motiv?", null, [ + h("div", { class: "sk-options" }, sourceBody), + ]), + ); + + // Upload + if (state.answers.motifSource === "upload") { + blocks.push( + renderCard( + "Motiv hochladen", + "Erlaubt: SVG, PDF, PNG, JPG, WEBP im A6-Format", + [ + h("input", { + class: "sk-input", + type: "file", + accept: ".svg,.pdf,image/*", + "data-sk-focus": "motif.upload", + onchange: (e) => { + const f = e.target.files && e.target.files[0]; + if (!f) return; + // Datei global speichern für späteren Upload + window.motifFileToUpload = f; + dispatch({ + type: "ANSWER", + patch: { + motifFileName: f.name, + motifFileMeta: { + name: f.name, + size: f.size, + type: f.type, + }, + }, + }); + }, + }), + state.answers.motifFileName + ? h("div", { + class: "sk-help", + text: `Ausgewählt: ${state.answers.motifFileName}`, + }) + : null, + ].filter(Boolean), + ), + ); + } + + // Info-Nachricht für "Bedruckte Karten zusenden" + if (state.answers.motifSource === "printed") { + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Alle weiteren Informationen, wohin die bedruckten Karten gesendet werden sollen, erhalten Sie danach per E-Mail.", + ]), + ); + } + + // Info-Nachricht für "Designservice" + if (state.answers.motifSource === "design") { + blocks.push( + h("div", { class: "sk-alert sk-alert-info" }, [ + "Unser Designservice erstellt ein individuelles Design, das perfekt auf Ihre Bedürfnisse zugeschnitten ist. " + + "Nach der Bestellung werden wir Sie kontaktieren, um Ihre Vorstellungen und Wünsche im Detail zu besprechen.", + ]), + ); + } + } + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Step 7: Kundendaten +function renderCustomerDataStep(state, dispatch) { + const blocks = []; + const b = state.order.billing; + + const fields = [ + { key: "firstName", label: "Vorname", required: true, type: "text" }, + { key: "lastName", label: "Nachname", required: true, type: "text" }, + ]; + + if (state.answers.customerType === "business") { + fields.push({ + key: "company", + label: "Unternehmensname", + required: true, + type: "text", + }); + } + + // E-Mail und Telefon (Pflichtfelder) + fields.push( + { key: "email", label: "E-Mail-Adresse", required: true, type: "email" }, + { key: "phone", label: "Telefonnummer", required: true, type: "tel" }, + ); + + fields.push( + { + key: "street", + label: "Straße und Hausnummer", + required: true, + type: "text", + }, + { key: "zip", label: "PLZ", required: true, type: "text" }, + { key: "city", label: "Stadt", required: true, type: "text" }, + ); + + const billingFields = fields.map((f) => + h("div", { class: "sk-field" }, [ + h("label", { + class: `sk-field-label ${f.required ? "is-required" : ""}`, + text: f.label, + }), + h("input", { + class: "sk-input", + type: f.type, + value: b[f.key] || "", + "data-sk-focus": `billing.${f.key}`, + ...(f.key === "zip" ? { pattern: "[0-9]*", inputmode: "numeric" } : {}), + oninput: (e) => { + let value = e.target.value; + // PLZ: Nur Zahlen erlauben + if (f.key === "zip") { + value = value.replace(/[^0-9]/g, ""); + e.target.value = value; + } + dispatch({ + type: "SET_ORDER_BILLING", + patch: { [f.key]: value }, + }); + }, + }), + ]), + ); + + billingFields.push( + h("div", { class: "sk-field" }, [ + h("label", { class: "sk-field-label is-required", text: "Land" }), + h( + "select", + { + class: "sk-select", + value: b.country || "Deutschland", + "data-sk-focus": "billing.country", + onchange: (e) => + dispatch({ + type: "SET_ORDER_BILLING", + patch: { country: e.target.value }, + }), + }, + [ + h("option", { value: "Deutschland" }, ["Deutschland"]), + h("option", { value: "Luxemburg" }, ["Luxemburg"]), + h("option", { value: "Frankreich" }, ["Frankreich"]), + h("option", { value: "Österreich" }, ["Österreich"]), + ], + ), + ]), + ); + + blocks.push(renderCard("Rechnungsdaten", null, billingFields)); + + // Alternative Lieferadresse + blocks.push( + renderCard("Lieferadresse", null, [ + h("label", { class: "sk-option", onclick: (e) => e.stopPropagation() }, [ + h("input", { + type: "checkbox", + checked: !!state.order.shippingDifferent, + onclick: (e) => e.stopPropagation(), + onchange: (e) => { + e.stopPropagation(); + dispatch({ + type: "SET_ORDER", + patch: { shippingDifferent: e.target.checked }, + }); + }, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { + class: "sk-option-label", + text: "Alternative Lieferadresse angeben", + }), + ]), + ]), + ]), + ); + + if (state.order.shippingDifferent) { + const sh = state.order.shipping; + const shippingFields = fields.map((f) => + h("div", { class: "sk-field" }, [ + h("label", { + class: `sk-field-label ${f.required ? "is-required" : ""}`, + text: f.label, + }), + h("input", { + class: "sk-input", + type: f.type, + value: sh[f.key] || "", + "data-sk-focus": `shipping.${f.key}`, + ...(f.key === "zip" + ? { pattern: "[0-9]*", inputmode: "numeric" } + : {}), + oninput: (e) => { + let value = e.target.value; + // PLZ: Nur Zahlen erlauben + if (f.key === "zip") { + value = value.replace(/[^0-9]/g, ""); + e.target.value = value; + } + dispatch({ + type: "SET_ORDER_SHIPPING", + patch: { [f.key]: value }, + }); + }, + }), + ]), + ); + + shippingFields.push( + h("div", { class: "sk-field" }, [ + h("label", { class: "sk-field-label is-required", text: "Land" }), + h( + "select", + { + class: "sk-select", + value: sh.country || "Deutschland", + "data-sk-focus": "shipping.country", + onchange: (e) => + dispatch({ + type: "SET_ORDER_SHIPPING", + patch: { country: e.target.value }, + }), + }, + [ + h("option", { value: "Deutschland" }, ["Deutschland"]), + h("option", { value: "Luxemburg" }, ["Luxemburg"]), + h("option", { value: "Frankreich" }, ["Frankreich"]), + h("option", { value: "Österreich" }, ["Österreich"]), + ], + ), + ]), + ); + + blocks.push(renderCard("Alternative Lieferadresse", null, shippingFields)); + } + + return h("div", { class: "sk-stack" }, blocks); +} + +// Motiv hochladen wenn vorhanden (vor Bestellabschluss) +async function uploadMotifIfNeeded(state, orderNumber) { + const api = window.SkriftBackendAPI; + if ( + state.answers.motifSource === "upload" && + window.motifFileToUpload && + api + ) { + try { + console.log("[Order] Uploading motif file..."); + const motifResult = await api.uploadMotif( + window.motifFileToUpload, + orderNumber, + ); + if (motifResult.success) { + console.log("[Order] Motif uploaded:", motifResult.url); + return motifResult.url; + } else { + console.error("[Order] Motif upload failed:", motifResult.error); + } + } catch (error) { + console.error("[Order] Motif upload error:", error); + } + } + return null; +} + +// Webhook-Aufruf nach Bestellung +async function handleOrderSubmit(state) { + const isB2B = state.answers?.customerType === "business"; + const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; + const webhookUrlBusiness = backend.webhook_url_business; + const webhookUrlPrivate = backend.webhook_url_private; + const redirectUrlBusiness = backend.redirect_url_business; + const redirectUrlPrivate = backend.redirect_url_private; + + // Bestellnummer generieren (fortlaufend vom WP-Backend) + const api = window.SkriftBackendAPI; + const orderNumber = api ? await api.generateOrderNumber() : `S-${Date.now()}`; + + // Motiv hochladen wenn vorhanden + const motifUrl = await uploadMotifIfNeeded(state, orderNumber); + if (motifUrl) { + state.answers.motifFileUrl = motifUrl; + } + + // Gutschein als verwendet markieren (falls vorhanden) + const voucherCode = state.order?.voucherStatus?.valid + ? state.order.voucherCode + : null; + if (voucherCode) { + try { + const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; + await fetch(restUrl + "skrift/v1/voucher/use", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: voucherCode }), + }); + } catch (error) { + // Fehler beim Markieren des Gutscheins - trotzdem fortfahren + } + } + + // Bestellung finalisieren: Preview-Dateien aus Cache in Output-Ordner kopieren + let finalizeResult = null; + if (api && api.sessionId) { + try { + finalizeResult = await api.finalizeOrder(api.sessionId, orderNumber, { + customer: state.order, + quote: state.quote, + }); + console.log("[Order] Finalize result:", finalizeResult); + } catch (error) { + console.error("[Order] Finalize error:", error); + } + } else { + console.warn("[Order] Cannot finalize - no API or session available"); + } + + // Für Business: Webhook sofort aufrufen + // Für Privat: Erst PayPal-Zahlung, dann Webhook + if (isB2B) { + // Business-Kunde: Webhook aufrufen und dann weiterleiten + if (webhookUrlBusiness) { + try { + const orderData = { + order_number: orderNumber, + customer_type: state.answers.customerType, + product: state.ctx?.product?.key, + quantity: state.answers.quantity, + format: state.answers.format, + shipping_mode: state.answers.shippingMode, + envelope: state.answers.envelope, + envelope_mode: state.answers.envelopeMode, + customer_data: state.order, + quote: state.quote, + timestamp: new Date().toISOString(), + finalize_result: finalizeResult, + }; + + const response = await fetch(webhookUrlBusiness, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(orderData), + }); + + if (!response.ok) { + alert( + "Bestellung wurde aufgenommen, aber es gab ein Problem bei der Übermittlung. Bitte kontaktieren Sie uns.", + ); + } + } catch (error) { + alert( + "Bestellung wurde aufgenommen, aber es gab ein Problem bei der Übermittlung. Bitte kontaktieren Sie uns.", + ); + } + } + + // Weiterleitung für Business-Kunden + if (redirectUrlBusiness) { + window.location.href = redirectUrlBusiness; + } else { + alert( + "Vielen Dank für Ihre Bestellung! Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details.", + ); + } + } else { + // Privatkunde: Ohne PayPal direkt zur Privat-URL weiterleiten + if (redirectUrlPrivate) { + window.location.href = redirectUrlPrivate; + } else { + alert( + "Weiterleitung zu PayPal folgt - Integration wird vorbereitet.\n\nNach erfolgreicher Zahlung wird der Webhook aufgerufen.", + ); + } + } +} + +// Hilfsfunktion: Gutschein validieren +function validateVoucherCode(voucherCode, dispatch) { + const code = voucherCode.trim(); + + if (!code) { + dispatch({ + type: "SET_ORDER", + patch: { voucherStatus: { valid: false, error: "Bitte Code eingeben" } }, + }); + return; + } + + const vouchers = window.SkriftConfigurator?.vouchers || {}; + const voucher = vouchers[code]; + + if (!voucher) { + dispatch({ + type: "SET_ORDER", + patch: { + voucherStatus: { valid: false, error: "Gutschein nicht gefunden" }, + }, + }); + return; + } + + if (voucher.expiry_date) { + // Vergleiche nur das Datum, nicht die Uhrzeit + const expiryDate = new Date(voucher.expiry_date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + expiryDate.setHours(0, 0, 0, 0); + + if (expiryDate < today) { + dispatch({ + type: "SET_ORDER", + patch: { + voucherStatus: { valid: false, error: "Gutschein ist abgelaufen" }, + }, + }); + return; + } + } + + if (voucher.usage_limit > 0 && voucher.usage_count >= voucher.usage_limit) { + dispatch({ + type: "SET_ORDER", + patch: { + voucherStatus: { + valid: false, + error: "Gutschein wurde bereits zu oft eingelöst", + }, + }, + }); + return; + } + + dispatch({ + type: "SET_ORDER", + patch: { + voucherStatus: { + valid: true, + voucher: voucher, + message: + voucher.type === "percent" + ? `${voucher.value}% Rabatt` + : `${voucher.value.toFixed(2)}€ Rabatt`, + }, + }, + }); +} + +// Step 8: Review +function renderReviewStep(state, dispatch) { + const blocks = []; + + // Zusammenfassung + const summary = [ + [ + "Kundentyp", + state.answers.customerType === "business" + ? "Geschäftskunde" + : "Privatkunde", + ], + ["Produkt", state?.ctx?.product?.label || ""], + ["Menge", String(state.answers.quantity || "")], + [ + "Format", + (() => { + const fmt = state.answers.format; + if (fmt === "a6p") return "A6 Hochformat"; + if (fmt === "a6l") return "A6 Querformat"; + if (fmt === "a4") return "A4"; + return fmt?.toUpperCase() || ""; + })(), + ], + [ + "Versand", + state.answers.shippingMode === "direct" + ? "Einzeln an Empfänger" + : "Gesammelt", + ], + ["Umschlag", state.answers.envelope ? "Ja" : "Nein"], + [ + "Beschriftung", + state.answers.envelopeMode === "recipientData" + ? "Empfängerdaten" + : state.answers.envelopeMode === "customText" + ? "Individuell" + : "Keine", + ], + ]; + + const table = h("table", { class: "sk-table" }, []); + const tbody = h("tbody", {}, []); + for (const [k, v] of summary) { + tbody.appendChild( + h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), + ); + } + table.appendChild(tbody); + + blocks.push(renderCard("Ihre Auswahl", null, [table])); + + // Kundendaten Übersicht (vor Preis!) + const b = state.order.billing; + const customerDataRows = [ + ["Vorname", b.firstName || ""], + ["Nachname", b.lastName || ""], + ]; + + if (state.answers.customerType === "business" && b.company) { + customerDataRows.push(["Unternehmen", b.company]); + } + + customerDataRows.push( + ["E-Mail", b.email || ""], + ["Telefon", b.phone || ""], + ["Straße und Hausnummer", b.street || ""], + ["PLZ", b.zip || ""], + ["Stadt", b.city || ""], + ["Land", b.country || "Deutschland"], + ); + + const customerDataTable = h("table", { class: "sk-table" }, []); + const customerDataBody = h("tbody", {}, []); + for (const [k, v] of customerDataRows) { + customerDataBody.appendChild( + h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), + ); + } + customerDataTable.appendChild(customerDataBody); + + blocks.push(renderCard("Ihre Daten", null, [customerDataTable])); + + // Preis + const isB2B = state.answers?.customerType === "business"; + const isFU = isFollowups(state); + + if (isFU) { + // Follow-ups: Zeige Staffelung + Einrichtungskosten + const followupPricing = calculateFollowupPricing(state); + + if (followupPricing) { + // Staffelungs-Tabelle + const staffelTable = h("table", { class: "sk-table" }, []); + staffelTable.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Menge/Monat" }), + h("th", { + text: isB2B + ? "Preis pro Schriftstück (netto)" + : "Preis pro Schriftstück (brutto)", + }), + ]), + ]), + ); + + // Grundpreis pro Stück (wie in Kopfzeile, OHNE Multiplikator) + const basePrice = calculateFollowupBasePricePerPiece(state); + + const staffelBody = h("tbody", {}, []); + for (const tier of followupPricing.tiers) { + // Preis pro Schriftstück = Grundpreis * Multiplikator + const pricePerItem = basePrice * tier.multiplier; + const formatted = formatPrice(pricePerItem, state); + const pricePerItemFormatted = formatted.display; + + staffelBody.appendChild( + h("tr", {}, [ + h("td", { text: tier.label }), + h("td", { text: pricePerItemFormatted }), + ]), + ); + } + staffelTable.appendChild(staffelBody); + + blocks.push(renderCard("Preisstaffelung", null, [staffelTable])); + + // Einrichtungskosten - detailliert aufschlüsseln + if (followupPricing.setup > 0) { + const setupItems = []; + const prices = window.SkriftConfigurator?.settings?.prices || {}; + + // API-Anbindung + if (state.answers.followupCreateMode === "auto") { + const apiPrice = prices.api_connection || 0; + if (apiPrice > 0) { + const formatted = formatPrice(apiPrice, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 8px 0;", + }, + [ + h("span", { text: "API-Anbindung" }), + h("span", { + style: "font-weight: 600", + text: formatted.display, + }), + ], + ), + ); + } + } + + // Motiv Upload + if (state.answers.motifSource === "upload") { + const motifPrice = prices.motif_upload || 0; + if (motifPrice > 0) { + const formatted = formatPrice(motifPrice, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 8px 0;", + }, + [ + h("span", { text: "Motiv Upload" }), + h("span", { + style: "font-weight: 600", + text: formatted.display, + }), + ], + ), + ); + } + } + + // Designservice + if (state.answers.motifSource === "design") { + const designPrice = prices.motif_design || 0; + if (designPrice > 0) { + const formatted = formatPrice(designPrice, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 8px 0;", + }, + [ + h("span", { text: "Designservice" }), + h("span", { + style: "font-weight: 600", + text: formatted.display, + }), + ], + ), + ); + } + } + + // Textservice + if (state.answers.contentCreateMode === "textservice") { + const textPrice = prices.textservice || 0; + if (textPrice > 0) { + const formatted = formatPrice(textPrice, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 8px 0;", + }, + [ + h("span", { text: "Textservice" }), + h("span", { + style: "font-weight: 600", + text: formatted.display, + }), + ], + ), + ); + } + } + + // Gesamt + const setupFormatted = formatPrice(followupPricing.setup, state); + setupItems.push( + h( + "div", + { + style: + "display: flex; justify-content: space-between; padding: 12px 0; border-top: 2px solid #ddd; margin-top: 8px; font-size: 18px; font-weight: 700", + }, + [ + h("span", { text: "Gesamt (einmalig)" }), + h("span", { text: setupFormatted.display }), + ], + ), + ); + + blocks.push(renderCard("Einrichtungskosten", null, setupItems)); + } + + // Versandhinweis + if (followupPricing.shippingNote) { + blocks.push( + h("div", { + class: "sk-alert sk-alert-info", + text: followupPricing.shippingNote, + }), + ); + } + } + } else { + // Standard-Produkte: Detaillierte Rechnung mit 4 Spalten + const lines = state.quote?.lines || []; + const priceTable = h("table", { class: "sk-table" }, []); + priceTable.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Position" }), + h("th", { text: "Menge", style: "text-align: center;" }), + h("th", { text: "Preis pro Stück", style: "text-align: right;" }), + h("th", { text: "Gesamt", style: "text-align: right;" }), + ]), + ]), + ); + + const priceBody = h("tbody", {}, []); + if (lines.length === 0) { + priceBody.appendChild( + h("tr", {}, [ + h("td", { + colspan: "4", + text: "Preisberechnung folgt nach Bestellung", + }), + ]), + ); + } else { + // Preis pro Stück für Hauptprodukt mit dynamischer Formel berechnen + const quantity = state.answers.quantity || 1; + const pricePerPieceNet = calculatePricePerPiece(state); + const taxRate = + (window.SkriftConfigurator?.settings?.prices?.tax_rate || 19) / 100; + const pricePerPieceGross = pricePerPieceNet * (1 + taxRate); + + for (const ln of lines) { + const isSubItem = ln.isSubItem || false; + const isMainProduct = ln.isMainProduct || false; + // Direktversand-Zeile soll Menge und Gesamtpreis anzeigen (nicht wie normale Sub-Items) + const isShippingLine = + ln.isShippingLine || + ln.description?.includes("Direktversand") || + ln.description?.includes("Sammelversand"); + const rowStyle = isSubItem + ? "padding-left: 20px; font-size: 14px; color: #666;" + : ""; + + // Bei Privatkunden: Bruttopreise anzeigen, bei Business: Nettopreise + const unitPrice = isB2B ? ln.unitNet : ln.unitGross; + const totalPrice = isB2B ? ln.totalNet : ln.totalGross; + + // Position: Bei Add-Ons (SubItems) "inkl." voranstellen + const positionText = isSubItem + ? `inkl. ${ln.description}` + : ln.description; + + // Bei Hauptprodukt: Summierten Preis pro Stück und Gesamtpreis anzeigen + // Preise auf 2 Dezimalstellen runden für konsistente Anzeige + const pricePerPieceDisplay = isMainProduct + ? isB2B + ? Math.round(pricePerPieceNet * 100) / 100 + : Math.round(pricePerPieceGross * 100) / 100 + : Math.round(unitPrice * 100) / 100; + + // Gesamt = gerundeter Einzelpreis × Menge (damit Menge × Stückpreis = Gesamt stimmt) + // Sub-Items zeigen normalerweise keinen Gesamtpreis, AUSSER Versand-Zeilen + const showQuantityAndTotal = !isSubItem || isShippingLine; + const totalPriceDisplay = isMainProduct + ? Math.round(pricePerPieceDisplay * quantity * 100) / 100 + : showQuantityAndTotal + ? Math.round(totalPrice * 100) / 100 + : null; + + const row = h("tr", {}, [ + h("td", { text: positionText, style: rowStyle }), + h("td", { + text: + showQuantityAndTotal && ln.quantity > 0 + ? ln.quantity.toString() + : "—", + style: "text-align: center;" + (isSubItem ? " color: #666;" : ""), + }), + h("td", { + text: fmtEUR(pricePerPieceDisplay), + style: "text-align: right;" + (isSubItem ? " color: #666;" : ""), + }), + h("td", { + text: + showQuantityAndTotal && totalPriceDisplay !== null + ? fmtEUR(totalPriceDisplay) + : "—", + style: "text-align: right;" + (isSubItem ? " color: #666;" : ""), + }), + ]); + priceBody.appendChild(row); + + // Steuerhinweis bei Versand (0% MwSt Anteil) + if (ln.taxNote) { + priceBody.appendChild( + h("tr", {}, [ + h("td", { + colspan: "4", + style: "font-size: 12px; color: #999; padding-left: 40px;", + text: ln.taxNote, + }), + ]), + ); + } + } + } + + // Gutschein-Rabatt anzeigen (falls vorhanden) + const hasDiscount = state.quote?.discountAmount > 0; + + if (hasDiscount) { + // Zwischensumme VOR Rabatt + priceBody.appendChild( + h("tr", { style: "border-top: 2px solid #ddd;" }, [ + h("td", { text: "Zwischensumme", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.totalBeforeDiscount || 0), + style: "text-align: right;", + }), + ]), + ); + + // Rabatt-Zeile + const voucherLabel = + state.quote?.voucher?.type === "percent" + ? `Gutschein (-${state.quote.voucher.value}%)` + : `Gutschein`; + + priceBody.appendChild( + h("tr", { style: "color: #2e7d32; font-weight: 600;" }, [ + h("td", { text: voucherLabel, colspan: "3" }), + h("td", { + text: "-" + fmtEUR(state.quote?.discountAmount || 0), + style: "text-align: right;", + }), + ]), + ); + } + + if (isB2B) { + // Business: Netto + MwSt getrennt + priceBody.appendChild( + h( + "tr", + { + style: + "font-weight: 700; border-top: " + + (hasDiscount ? "1px solid #ddd" : "2px solid #ddd") + + ";", + }, + [ + h("td", { text: "Gesamtpreis (zzgl. MwSt.)", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.subtotalNet || 0), + style: "text-align: right;", + }), + ], + ), + ); + + priceBody.appendChild( + h("tr", { style: "font-weight: 700; font-size: 16px" }, [ + h("td", { text: "Gesamtpreis (inkl. MwSt.)", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.totalGross || 0), + style: "text-align: right;", + }), + ]), + ); + + // MwSt. Ausweis unter Gesamtpreis (inkl. MwSt.) + priceBody.appendChild( + h("tr", { style: "font-size: 14px; color: #666;" }, [ + h("td", { text: "enthaltene MwSt. 19%", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.vatAmount || 0), + style: "text-align: right;", + }), + ]), + ); + } else { + // Privatkunde: Gesamtpreis + MwSt. Ausweis darunter + priceBody.appendChild( + h( + "tr", + { + style: + "font-weight: 700; font-size: 16px; border-top: " + + (hasDiscount ? "1px solid #ddd" : "2px solid #ddd") + + ";", + }, + [ + h("td", { text: "Gesamtpreis (inkl. MwSt.)", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.totalGross || 0), + style: "text-align: right;", + }), + ], + ), + ); + + // MwSt. Ausweis unter Gesamtpreis + priceBody.appendChild( + h("tr", { style: "font-size: 14px; color: #666;" }, [ + h("td", { text: "enthaltene MwSt. 19%", colspan: "3" }), + h("td", { + text: fmtEUR(state.quote?.vatAmount || 0), + style: "text-align: right;", + }), + ]), + ); + } + + priceTable.appendChild(priceBody); + + // Gutschein-Bereich + const voucherCode = state.order?.voucherCode || ""; + const voucherStatus = state.order?.voucherStatus || null; + const showVoucherInput = state.order?.showVoucherInput || false; + + const voucherSection = []; + + if (!showVoucherInput && !voucherStatus?.valid) { + // Zeige klickbaren Link + voucherSection.push( + h("a", { + style: + "margin-top: 15px; display: block; text-decoration: underline; color: #999; font-size: 13px; cursor: pointer;", + text: "Gutschein oder Aktionscode?", + onclick: (e) => { + e.preventDefault(); + dispatch({ + type: "SET_ORDER", + patch: { showVoucherInput: true }, + }); + }, + }), + ); + } else if (showVoucherInput || voucherStatus?.valid) { + // Zeige Eingabefeld + voucherSection.push( + h( + "div", + { + style: + "margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0;", + }, + [ + h( + "div", + { + style: "display: flex; gap: 10px; align-items: flex-start;", + }, + [ + h("input", { + type: "text", + class: "sk-input", + style: "flex: 1; text-transform: uppercase;", + placeholder: "Gutschein- oder Aktionscode eingeben", + value: voucherCode, + disabled: voucherStatus?.valid, + "data-sk-focus": "voucher.code", + oninput: (e) => { + dispatch({ + type: "SET_ORDER", + patch: { + voucherCode: e.target.value.toUpperCase(), + voucherStatus: null, + }, + }); + }, + }), + voucherStatus?.valid + ? h("button", { + type: "button", + class: "sk-btn sk-btn-secondary", + text: "Entfernen", + onclick: () => { + dispatch({ + type: "SET_ORDER", + patch: { + voucherCode: "", + voucherStatus: null, + showVoucherInput: false, + }, + }); + }, + }) + : h("button", { + type: "button", + class: "sk-btn sk-btn-secondary", + text: "Einlösen", + onclick: () => validateVoucherCode(voucherCode, dispatch), + }), + ], + ), + voucherStatus + ? h("div", { + class: voucherStatus.valid ? "sk-help" : "sk-error-message", + style: `margin-top: 8px; color: ${ + voucherStatus.valid ? "#2e7d32" : "#d32f2f" + };`, + text: voucherStatus.valid + ? `✓ ${voucherStatus.message}` + : `✗ ${voucherStatus.error}`, + }) + : null, + ].filter(Boolean), + ), + ); + } + + const priceCardContent = [priceTable, ...voucherSection]; + + // Preis-Card mit spezieller Klasse für noPrice-Modus + const priceCard = renderCard("Preis", null, priceCardContent); + priceCard.classList.add("sk-price-card"); + blocks.push(priceCard); + } + + // Gutschein ist jetzt für alle Produkte in der Preis-Card integriert (siehe oben) + + // Alternative Lieferadresse (falls vorhanden) + if (state.order.shippingDifferent) { + const sh = state.order.shipping; + const shippingDataRows = [ + ["Vorname", sh.firstName || ""], + ["Nachname", sh.lastName || ""], + ]; + + if (state.answers.customerType === "business" && sh.company) { + shippingDataRows.push(["Unternehmen", sh.company]); + } + + shippingDataRows.push( + ["Straße und Hausnummer", sh.street || ""], + ["PLZ", sh.zip || ""], + ["Stadt", sh.city || ""], + ["Land", sh.country || "Deutschland"], + ); + + const shippingDataTable = h("table", { class: "sk-table" }, []); + const shippingDataBody = h("tbody", {}, []); + for (const [k, v] of shippingDataRows) { + shippingDataBody.appendChild( + h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), + ); + } + shippingDataTable.appendChild(shippingDataBody); + + blocks.push(renderCard("Lieferadresse", null, [shippingDataTable])); + } + + // Rechtliches mit Bestellbutton + blocks.push( + renderCard("Rechtliches", null, [ + h( + "label", + { + class: "sk-option", + style: "margin-bottom: 12px", + onclick: (e) => e.stopPropagation(), + }, + [ + h("input", { + type: "checkbox", + id: "sk-agb-checkbox", + checked: !!state.order.acceptedAgb, + onclick: (e) => e.stopPropagation(), + onchange: (e) => { + e.stopPropagation(); + dispatch({ + type: "SET_ORDER", + patch: { acceptedAgb: e.target.checked }, + }); + }, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label" }, [ + document.createTextNode("Ich habe die "), + h("a", { + href: "https://skrift.de/agb/", + target: "_blank", + rel: "noopener noreferrer", + text: "Allgemeinen Geschäftsbedingungen", + onclick: (e) => e.stopPropagation(), + }), + document.createTextNode( + " gelesen und akzeptiere deren Geltung. Ein Widerrufsrecht besteht gemäß § 312g Abs. 2 Nr. 1 BGB nicht, da Skrift ausschließlich Waren anbietet, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Kunden maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Kunden zugeschnitten sind.", + ), + ]), + ]), + ], + ), + h("label", { class: "sk-option", onclick: (e) => e.stopPropagation() }, [ + h("input", { + type: "checkbox", + id: "sk-privacy-checkbox", + checked: !!state.order.acceptedPrivacy, + onclick: (e) => e.stopPropagation(), + onchange: (e) => { + e.stopPropagation(); + dispatch({ + type: "SET_ORDER", + patch: { acceptedPrivacy: e.target.checked }, + }); + }, + }), + h("div", { class: "sk-option-content" }, [ + h("div", { class: "sk-option-label" }, [ + document.createTextNode("Ja, ich habe die "), + h("a", { + href: "https://skrift.de/datenschutz/", + target: "_blank", + rel: "noopener noreferrer", + text: "Datenschutzerklärung", + onclick: (e) => e.stopPropagation(), + }), + document.createTextNode( + " zur Kenntnis genommen und bin damit einverstanden, dass die von mir angegebenen Daten elektronisch erhoben und gespeichert werden. Meine Daten werden dabei nur streng zweckgebunden zur Bearbeitung und Beantwortung meiner Anfrage benutzt. Mit dem Absenden des Kontaktformulars erkläre ich mich mit der Verarbeitung einverstanden. Die Daten werden im Falle eines ausbleibenden Auftrags gelöscht.", + ), + ]), + ]), + ]), + // Bestellbutton - für Privatkunden mit PayPal oder normaler Button + renderOrderButton(state), + ]), + ); + + return h("div", { class: "sk-stack" }, blocks); +} + +/** + * Rendert den Bestellbutton basierend auf Kundentyp und PayPal-Konfiguration + */ +function renderOrderButton(state) { + const isB2B = state.answers?.customerType === "business"; + const paypalConfig = window.SkriftConfigurator?.paypal || {}; + const isPayPalEnabled = paypalConfig.enabled && paypalConfig.client_id; + const isNoPrice = state.noPrice || false; + + // Geschäftskunden, PayPal nicht aktiviert, oder noPrice-Modus: Normaler Button + if (isB2B || !isPayPalEnabled || isNoPrice) { + return h("button", { + type: "button", + class: "sk-btn sk-btn-primary sk-btn-large", + style: "width: 100%; margin-top: 20px;", + disabled: !validateStep(state), + onclick: () => handleOrderSubmit(state), + text: "Jetzt kostenpflichtig bestellen", + }); + } + + // Prüfen ob AGB und Datenschutz akzeptiert wurden + const agbAccepted = state.order?.acceptedAgb || false; + const privacyAccepted = state.order?.acceptedPrivacy || false; + const isValid = agbAccepted && privacyAccepted; + + // Privatkunden mit PayPal: PayPal-Button-Container + const container = h( + "div", + { + style: "margin-top: 20px; position: relative;", + }, + [ + h("div", { + id: "paypal-button-container", + style: `min-height: 50px; ${ + !isValid ? "opacity: 0.5; pointer-events: none;" : "" + }`, + }), + h("div", { + class: "sk-paypal-loading", + style: "text-align: center; padding: 15px; color: #666;", + text: "PayPal wird geladen...", + }), + // Hinweis wenn Checkboxen nicht aktiviert + !isValid + ? h("div", { + style: + "text-align: center; padding: 10px; color: #dc2626; font-size: 14px; margin-top: 5px;", + text: "Bitte akzeptieren Sie die AGB und Datenschutzerklärung, um fortzufahren.", + }) + : null, + ].filter(Boolean), + ); + + // PayPal SDK laden und Button initialisieren + loadPayPalSDK(paypalConfig.client_id) + .then(() => { + initPayPalButton(state); + }) + .catch((err) => { + console.error("[PayPal] SDK load failed:", err); + const loadingEl = document.querySelector(".sk-paypal-loading"); + if (loadingEl) { + loadingEl.innerHTML = + "PayPal konnte nicht geladen werden. Bitte versuchen Sie es später erneut."; + loadingEl.style.color = "#dc2626"; + } + }); + + return container; +} + +/** + * Lädt die PayPal SDK dynamisch + */ +let paypalSDKLoaded = false; +let paypalSDKPromise = null; + +function loadPayPalSDK(clientId) { + if (paypalSDKLoaded) { + return Promise.resolve(); + } + + if (paypalSDKPromise) { + return paypalSDKPromise; + } + + paypalSDKPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + // SDK URL - einfache Version ohne buyer-country (das verursacht 400 Fehler) + script.src = `https://www.paypal.com/sdk/js?client-id=${clientId}¤cy=EUR&intent=capture&disable-funding=venmo,paylater,credit`; + script.async = true; + + script.onload = () => { + paypalSDKLoaded = true; + resolve(); + }; + + script.onerror = () => { + paypalSDKPromise = null; + reject(new Error("Failed to load PayPal SDK")); + }; + + document.head.appendChild(script); + }); + + return paypalSDKPromise; +} + +/** + * Initialisiert den PayPal-Button mit Client-Side Integration + * Keine Backend-Aufrufe nötig - alles läuft direkt über PayPal API + */ +function initPayPalButton(state) { + const container = document.getElementById("paypal-button-container"); + const loadingEl = document.querySelector(".sk-paypal-loading"); + + if (!container) { + console.error("[PayPal] Button container not found"); + return; + } + + // Loading-Text entfernen + if (loadingEl) { + loadingEl.style.display = "none"; + } + + // Prüfen ob Button bereits existiert + if (container.hasChildNodes()) { + return; + } + + if (!window.paypal) { + console.error("[PayPal] SDK not available"); + return; + } + + window.paypal + .Buttons({ + style: { + shape: "rect", + layout: "vertical", + color: "blue", + label: "paypal", + }, + + // Bestellung direkt über PayPal API erstellen (Client-Side) + createOrder(data, actions) { + const currentState = window.currentGlobalState; + + // State muss vorhanden sein + if (!currentState) { + alert( + "Fehler: State nicht verfügbar. Bitte laden Sie die Seite neu.", + ); + return Promise.reject(new Error("State nicht verfügbar")); + } + + // Checkbox-Werte direkt aus dem DOM lesen als Fallback + const agbCheckbox = document.getElementById("sk-agb-checkbox"); + const privacyCheckbox = document.getElementById("sk-privacy-checkbox"); + + const agbAccepted = + currentState.order?.acceptedAgb || agbCheckbox?.checked; + const privacyAccepted = + currentState.order?.acceptedPrivacy || privacyCheckbox?.checked; + + // Validierung prüfen - spezifische Fehlermeldung + if (!agbAccepted) { + alert("Bitte akzeptieren Sie die Allgemeinen Geschäftsbedingungen."); + return Promise.reject(new Error("AGB nicht akzeptiert")); + } + if (!privacyAccepted) { + alert("Bitte akzeptieren Sie die Datenschutzerklärung."); + return Promise.reject(new Error("Datenschutz nicht akzeptiert")); + } + + const quote = currentState.quote || {}; + const total = quote.totalGross || 0; + + if (total <= 0) { + alert( + "Ungültiger Bestellbetrag. Bitte konfigurieren Sie Ihre Bestellung.", + ); + return Promise.reject(new Error("Ungültiger Bestellbetrag")); + } + + // Betrag auf 2 Dezimalstellen formatieren + const formattedTotal = parseFloat(total).toFixed(2); + + // Produktname für PayPal + const productName = + currentState.ctx?.product?.label || "Skrift Handschriftservice"; + const quantity = currentState.answers?.quantity || 1; + + return actions.order.create({ + intent: "CAPTURE", + purchase_units: [ + { + amount: { + currency_code: "EUR", + value: formattedTotal, + }, + description: `${quantity}x ${productName}`, + }, + ], + application_context: { + brand_name: "Skrift", + locale: "de-DE", + shipping_preference: "NO_SHIPPING", + user_action: "PAY_NOW", + }, + }); + }, + + // Zahlung direkt über PayPal API erfassen (Client-Side) + onApprove(data, actions) { + return actions.order.capture().then(function (orderData) { + // Erfolgreiche Transaktion + const transaction = + orderData?.purchase_units?.[0]?.payments?.captures?.[0]; + + if (transaction?.status === "COMPLETED") { + // Webhook aufrufen und weiterleiten + handlePayPalSuccess(window.currentGlobalState || state, orderData); + } else { + alert( + "Zahlung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", + ); + } + }); + }, + + // Zahlung abgebrochen + onCancel() { + alert("Zahlung wurde abgebrochen."); + }, + + // Fehler + onError(err) { + console.error("[PayPal] Error:", err); + alert( + "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + ); + }, + }) + .render("#paypal-button-container"); +} + +/** + * Behandelt erfolgreiche PayPal-Zahlung + */ +async function handlePayPalSuccess(state, paypalDetails) { + const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; + const webhookUrlPrivate = backend.webhook_url_private; + const redirectUrlPrivate = backend.redirect_url_private; + + // Bestellnummer generieren (fortlaufend vom WP-Backend) + const api = window.SkriftBackendAPI; + const orderNumber = api ? await api.generateOrderNumber() : `S-${Date.now()}`; + + // Motiv hochladen wenn vorhanden + const motifUrl = await uploadMotifIfNeeded(state, orderNumber); + if (motifUrl) { + state.answers.motifFileUrl = motifUrl; + } + + // Gutschein als verwendet markieren (falls vorhanden) + const voucherCode = state.order?.voucherStatus?.valid + ? state.order.voucherCode + : null; + if (voucherCode) { + try { + const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; + await fetch(restUrl + "skrift/v1/voucher/use", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: voucherCode }), + }); + } catch (error) { + // Fehler beim Markieren des Gutscheins - trotzdem fortfahren + } + } + + // Bestellung finalisieren: Preview-Dateien aus Cache in Output-Ordner kopieren + let finalizeResult = null; + if (api && api.sessionId) { + try { + finalizeResult = await api.finalizeOrder(api.sessionId, orderNumber, { + customer: state.order, + payment: { + method: "paypal", + transaction_id: paypalDetails.id, + payer_email: paypalDetails.payer?.email_address, + status: paypalDetails.status, + }, + quote: state.quote, + }); + console.log("[PayPal] Finalize result:", finalizeResult); + } catch (error) { + console.error("[PayPal] Finalize error:", error); + } + } else { + console.warn("[PayPal] Cannot finalize - no API or session available"); + } + + // Webhook für Privatkunden mit PayPal-Details aufrufen + if (webhookUrlPrivate) { + try { + const orderData = { + order_number: orderNumber, + customer_type: state.answers.customerType, + product: state.ctx?.product?.key, + quantity: state.answers.quantity, + format: state.answers.format, + shipping_mode: state.answers.shippingMode, + envelope: state.answers.envelope, + envelope_mode: state.answers.envelopeMode, + customer_data: state.order, + quote: state.quote, + timestamp: new Date().toISOString(), + payment: { + method: "paypal", + transaction_id: paypalDetails.id, + payer_email: paypalDetails.payer?.email_address, + status: paypalDetails.status, + }, + finalize_result: finalizeResult, + }; + + const response = await fetch(webhookUrlPrivate, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(orderData), + }); + + if (!response.ok) { + console.error("[PayPal] Webhook failed:", response.status); + } + } catch (error) { + console.error("[PayPal] Webhook error:", error); + } + } + + // Weiterleitung für Privatkunden + if (redirectUrlPrivate) { + window.location.href = redirectUrlPrivate; + } else { + alert( + "Vielen Dank für Ihre Zahlung! Ihre Bestellung wurde erfolgreich aufgenommen. Sie erhalten in Kürze eine Bestätigungs-E-Mail.", + ); + } +} + +// Helper: CSV +function parseCsv(text) { + const raw = String(text || "").trim(); + if (!raw) return []; + const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); + const sample = lines[0]; + const delim = sample.includes(";") ? ";" : ","; + + return lines.map((ln) => { + const cells = ln.split(delim).map((c) => { + let value = c.trim(); + + // CSV Injection Prevention: Entferne gefährliche Zeichen am Anfang + // Dies verhindert Formula Injection in Excel/LibreOffice + if (value.length > 0 && /^[=+\-@]/.test(value)) { + value = "'" + value; // Prefix mit ' um als Text zu markieren + } + + return value; + }); + return cells; + }); +} + +function escapeCsvValue(value) { + let str = String(value || ""); + + // CSV Injection Prevention: Entferne gefährliche Zeichen am Anfang + // Dies verhindert Formula Injection in Excel/LibreOffice + if (str.length > 0 && /^[=+\-@]/.test(str)) { + str = "'" + str; // Prefix mit ' um als Text zu markieren + } + + // Semikolon und Anführungszeichen escapen + if (str.includes(";") || str.includes('"') || str.includes("\n")) { + return '"' + str.replaceAll('"', '""') + '"'; + } + return str; +} + +function rowsToCsv(rows) { + const header = [ + "vorname", + "nachname", + "straße", + "hausnummer", + "plz", + "stadt", + "land", + ]; + const lines = [header.join(";")]; + for (const r of rows) { + lines.push( + [ + r.firstName || "", + r.lastName || "", + r.street || "", + r.houseNumber || "", + r.zip || "", + r.city || "", + r.country || "", + ] + .map((v) => escapeCsvValue(v)) + .join(";"), + ); + } + return lines.join("\n"); +} + +function downloadText(filename, text) { + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +// Generische Tabellen-Modal Funktion +// Diese Funktion erstellt ein Modal mit einer Tabelle, die Import/Export unterstützt +// und sich automatisch nach einem Import aktualisiert. +// +// config Parameter: +// - title: Titel des Modals +// - columns: Array von {key, label} Objekten +// - getData: Funktion die den aktuellen State nimmt und die Daten zurückgibt (Array von Objekten) +// - setData: Funktion die dispatch und neue Daten nimmt und den State aktualisiert +// - rowCount: Anzahl der Zeilen +// - exportFilename: Dateiname für CSV Export +// - helpText: Optionaler Hilfetext unter der Tabelle +// - getStateFunc: Funktion die den aktuellen State zurückgibt (wichtig für Re-Rendering!) +function createTableModal(dispatch, config) { + let tableWrapper; + let currentState = config.getStateFunc(); + + // State-Referenz aktualisieren (wird nach jedem Dispatch aufgerufen) + const updateState = () => { + currentState = config.getStateFunc(); + }; + + // Render-Funktion die die Tabelle neu zeichnet (muss vor buildTable definiert werden) + const renderTable = () => { + requestAnimationFrame(() => { + const oldTable = tableWrapper.querySelector("table"); + if (oldTable) oldTable.remove(); + tableWrapper.appendChild(buildTable()); + }); + }; + + // Baut die Tabelle mit den aktuellen Daten + function buildTable() { + updateState(); // State aktualisieren vor dem Rendern + const data = config.getData(currentState); + const rowCount = config.rowCount(currentState); + + const table = h( + "table", + { class: "sk-table sk-table-pasteable", tabindex: "0" }, + [], + ); + + // Header (Fragment verwenden für bessere Performance) + const headerCells = [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + ]; + config.columns.forEach((col) => { + const attrs = { text: col.label }; + if (col.readOnly) { + attrs.class = "sk-table-readonly-header"; + } + headerCells.push(h("th", attrs)); + }); + table.appendChild(h("thead", {}, [h("tr", {}, headerCells)])); + + // Body - Batch DOM updates + const tbody = h("tbody", {}, []); + const rows = []; + + for (let rowIdx = 0; rowIdx < rowCount; rowIdx++) { + const rowData = data[rowIdx] || {}; + const cells = [ + h("td", { text: String(rowIdx + 1), class: "sk-table-row-number" }), + ]; + + config.columns.forEach((col, colIdx) => { + const value = rowData[col.key] || ""; + const cellAttrs = {}; + if (col.readOnly) { + cellAttrs.class = "sk-table-readonly-cell"; + } + + // Optimierung: Input Handler mit direktem Dispatch ohne komplette Daten-Kopie + cells.push( + h("td", cellAttrs, [ + h("input", { + class: "sk-input", + type: "text", + value: value, + placeholder: col.placeholder || "", + readonly: col.readOnly || false, + "data-row": String(rowIdx), + "data-col": String(colIdx), + "data-key": col.key, + "data-sk-focus": `modal.${col.key}.${rowIdx}`, + oninput: col.readOnly + ? null + : (e) => { + // Scroll-Position VOR dem dispatch speichern + const modalContent = e.target.closest(".sk-modal-content"); + const scrollTop = modalContent ? modalContent.scrollTop : 0; + const cursorPos = e.target.selectionStart; + const inputValue = e.target.value; + + updateState(); + const newData = config + .getData(currentState) + .map((r) => ({ ...r })); + if (!newData[rowIdx]) newData[rowIdx] = {}; + newData[rowIdx][col.key] = inputValue; + config.setData(dispatch, newData); + + // Fokus und Scroll-Position nach Render wiederherstellen + setTimeout(() => { + const input = document.querySelector( + `input[data-row="${rowIdx}"][data-key="${col.key}"]`, + ); + if (input) { + input.focus({ preventScroll: true }); + input.setSelectionRange(cursorPos, cursorPos); + + // Modal Scroll-Position wiederherstellen + const modal = input.closest(".sk-modal-content"); + if (modal) { + modal.scrollTop = scrollTop; + } + } + }, 0); + }, + }), + ]), + ); + }); + + rows.push(h("tr", {}, cells)); + } + + // Batch append mit DocumentFragment für maximale Performance + const fragment = document.createDocumentFragment(); + rows.forEach((row) => fragment.appendChild(row)); + tbody.appendChild(fragment); + table.appendChild(tbody); + + // Excel Paste Handler mit automatischer Tabellen-Aktualisierung + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + + if (!text || !text.trim()) return; + + const rows = text.split(/\r?\n/).filter((r) => r.trim()); + if (rows.length === 0) return; + + const activeInput = document.activeElement; + if (!activeInput || !activeInput.classList.contains("sk-input")) return; + + const startRow = parseInt(activeInput.dataset.row || "0"); + const startCol = parseInt(activeInput.dataset.col || "0"); + + updateState(); + const currentData = config.getData(currentState); + const currentRowCount = config.rowCount(currentState); + const newData = currentData.map((r) => ({ ...r })); + + // Sicherstellen dass genug Zeilen existieren + while (newData.length < currentRowCount) { + newData.push({}); + } + + let pastedCells = 0; + rows.forEach((rowText, rowOffset) => { + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetRow = startRow + rowOffset; + const targetCol = startCol + colOffset; + + if ( + targetRow < currentRowCount && + targetCol < config.columns.length + ) { + const col = config.columns[targetCol]; + if (!col.readOnly) { + if (!newData[targetRow]) newData[targetRow] = {}; + newData[targetRow][col.key] = cellValue.trim(); + pastedCells++; + } + } + }); + }); + + if (pastedCells > 0) { + config.setData(dispatch, newData); + + // Tabelle im Modal aktualisieren + renderTable(); + } + }); + + return table; + } + + // CSV Export + function exportCsv() { + updateState(); + const data = config.getData(currentState); + const headers = config.columns.map((col) => col.label).join(";"); + const rows = data.map((row) => + config.columns.map((col) => escapeCsvValue(row[col.key] || "")).join(";"), + ); + const csv = [headers, ...rows].join("\n"); + downloadText(config.exportFilename, csv); + } + + // CSV Import + async function handleImport(file) { + if (!file) return; + + const text = await file.text(); + const parsed = parseCsv(text); + + if (parsed.length === 0) { + alert("Die CSV-Datei ist leer."); + return; + } + + // Strikte Header-Validierung + let startRow = 0; + let headerValid = false; + + if (parsed.length > 0 && parsed[0].length > 0) { + const firstRow = parsed[0]; + + // Prüfe ob erste Zeile wie eine Header-Zeile aussieht + const commonHeaders = [ + "vorname", + "nachname", + "name", + "straße", + "strasse", + "plz", + "stadt", + "land", + "nr", + "ort", + ]; + const hasCommonHeaders = firstRow.some((cell) => { + const lower = cell.toLowerCase().trim(); + return commonHeaders.some((header) => lower.includes(header)); + }); + + const hasPlaceholderSyntax = firstRow.some( + (cell) => cell.includes("[[") && cell.includes("]]"), + ); + + const looksLikeHeader = hasCommonHeaders || hasPlaceholderSyntax; + + if (looksLikeHeader) { + // Validiere die Spaltenüberschriften + const editableColumns = config.columns.filter((col) => !col.readOnly); + + // Prüfe ob alle erwarteten Spalten vorhanden sind + let matchCount = 0; + const expectedHeaders = editableColumns.map((col) => { + const labelCleaned = col.label + .replace(/\[\[|\]\]/g, "") + .toLowerCase() + .trim(); + return { + key: col.key.toLowerCase(), + label: labelCleaned, + original: col.label, + }; + }); + + for ( + let i = 0; + i < Math.min(firstRow.length, config.columns.length); + i++ + ) { + const cellLower = firstRow[i].toLowerCase().trim(); + const col = config.columns[i]; + + if (col.readOnly) continue; // Überspringe read-only Spalten + + const labelCleaned = col.label + .replace(/\[\[|\]\]/g, "") + .toLowerCase() + .trim(); + const keyLower = col.key.toLowerCase(); + + // Exakte oder ähnliche Übereinstimmung + if ( + cellLower === keyLower || + cellLower === labelCleaned || + cellLower.includes(labelCleaned) || + labelCleaned.includes(cellLower) + ) { + matchCount++; + } + } + + // Header ist gültig wenn mindestens 50% der editierbaren Spalten übereinstimmen + const requiredMatches = Math.ceil(editableColumns.length * 0.5); + headerValid = matchCount >= requiredMatches; + + if (headerValid) { + startRow = 1; + } else { + // Header-Zeile erkannt aber ungültig + const expectedHeaderNames = expectedHeaders + .map((h) => h.original) + .join(", "); + const actualHeaderNames = firstRow.join(", "); + + alert( + `Die Spaltenüberschriften stimmen nicht überein.\n\n` + + `Erwartet: ${expectedHeaderNames}\n\n` + + `Gefunden: ${actualHeaderNames}\n\n` + + `Bitte stellen Sie sicher, dass die CSV-Datei die richtigen Spaltenüberschriften hat.`, + ); + return; + } + } + } + + // Prüfe ob genug Datenzeilen vorhanden sind + if (parsed.length <= startRow) { + alert("Die CSV-Datei enthält keine Datenzeilen."); + return; + } + + updateState(); + const rowCount = config.rowCount(currentState); + const newData = []; + + for (let i = 0; i < rowCount; i++) { + const row = {}; + const sourceRow = parsed[startRow + i]; + if (sourceRow) { + config.columns.forEach((col, colIdx) => { + if (!col.readOnly) { + row[col.key] = sourceRow[colIdx] || ""; + } + }); + } + newData.push(row); + } + + config.setData(dispatch, newData); + + // Tabelle neu rendern nach Import (requestAnimationFrame für bessere Performance) + requestAnimationFrame(() => renderTable()); + } + + const fileInput = h("input", { + type: "file", + accept: ".csv,text/csv", + style: "display: none;", + onchange: async (e) => { + const f = e.target.files && e.target.files[0]; + await handleImport(f); + e.target.value = ""; + }, + }); + + const actions = h( + "div", + { class: "sk-inline", style: "margin-bottom: 16px;" }, + [ + fileInput, + h("button", { + type: "button", + class: "sk-btn", + text: "CSV importieren", + onclick: () => fileInput.click(), + }), + h("button", { + type: "button", + class: "sk-btn", + text: "CSV exportieren", + onclick: exportCsv, + }), + ], + ); + + tableWrapper = h("div", { class: "sk-table-wrapper-modal" }, [buildTable()]); + + const modalContent = h("div", { class: "sk-modal-content" }, [ + h("div", { class: "sk-modal-header" }, [ + h("h3", { text: config.title }), + h("button", { + type: "button", + class: "sk-modal-close", + text: "×", + onclick: () => modal.remove(), + }), + ]), + h( + "div", + { class: "sk-modal-body" }, + [ + config.infoText + ? h("div", { + class: "sk-alert sk-alert-info", + style: "margin-bottom: 16px; font-size: 13px;", + text: config.infoText, + }) + : null, + actions, + tableWrapper, + config.helpText + ? h("div", { + class: "sk-help", + style: "margin-top: 12px;", + text: config.helpText, + }) + : null, + ].filter((x) => x !== null), + ), + ]); + + const modal = h( + "div", + { + class: "sk-modal", + onclick: (e) => { + if (e.target.classList.contains("sk-modal")) modal.remove(); + }, + }, + [modalContent], + ); + + const configuratorElement = + document.querySelector(".sk-configurator") || document.body; + configuratorElement.appendChild(modal); + + // Focus auf erste editierbare Zelle + setTimeout(() => { + const firstInput = tableWrapper.querySelector("input:not([readonly])"); + if (firstInput) firstInput.focus(); + }, 100); +} + +// Helper: Empfänger Tabelle +function renderRecipientsTable(state, dispatch) { + const addressMode = state.addressMode || "classic"; + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + const isDirectShipping = state.answers.shippingMode === "direct"; + + // Klassische Adresstabelle + function buildClassicTable(currentState) { + const rows = Array.isArray(currentState.recipientRows) + ? currentState.recipientRows + : []; + + const table = h( + "table", + { class: "sk-table sk-table-pasteable", tabindex: "0" }, + [], + ); + // Rows-Referenz am Table speichern für Event-Handler + table._rows = rows; + + table.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + h("th", { text: "Vorname" }), + h("th", { text: "Nachname" }), + h("th", { text: "Straße" }), + h("th", { text: "Nr." }), + h("th", { text: "PLZ" }), + h("th", { text: "Stadt" }), + h("th", { text: "Land" }), + ]), + ]), + ); + + const tbody = h("tbody", {}, []); + for (let idx = 0; idx < want; idx++) { + const r = rows[idx] || {}; + const cell = (key, placeholder = "") => + h("td", {}, [ + h("input", { + class: "sk-input", + type: "text", + value: r[key] || "", + placeholder, + "data-row": idx, + "data-col": key, + "data-sk-focus": `recipient.${key}.${idx}`, + oninput: (e) => { + const currentRows = table._rows; + if (!currentRows[idx]) { + currentRows[idx] = { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }; + } + currentRows[idx][key] = e.target.value; + }, + }), + ]); + + const countryCell = h("td", {}, [ + h("input", { + class: "sk-input", + type: "text", + value: r.country || (isDirectShipping ? "Deutschland" : ""), + placeholder: isDirectShipping ? "Deutschland" : "", + "data-row": idx, + "data-col": "country", + "data-sk-focus": `recipient.country.${idx}`, + oninput: (e) => { + const currentRows = table._rows; + if (!currentRows[idx]) { + currentRows[idx] = { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }; + } + currentRows[idx].country = e.target.value; + }, + }), + ]); + + tbody.appendChild( + h("tr", {}, [ + h("td", { text: String(idx + 1), class: "sk-table-row-number" }), + cell("firstName"), + cell("lastName"), + cell("street"), + cell("houseNumber"), + cell("zip"), + cell("city"), + countryCell, + ]), + ); + } + table.appendChild(tbody); + + // Focusout-Handler mit Debounce um Flackern zu vermeiden + let focusoutTimer = null; + let hasLocalChanges = false; // Track ob lokale Änderungen gemacht wurden + table.addEventListener("focusout", (e) => { + if (focusoutTimer) clearTimeout(focusoutTimer); + focusoutTimer = setTimeout(() => { + const newFocus = document.activeElement; + if (!table.contains(newFocus) && hasLocalChanges) { + // Nur dispatchen wenn lokale Änderungen gemacht wurden + dispatch({ type: "SET_RECIPIENT_ROWS", rows: [...table._rows] }); + hasLocalChanges = false; + } + }, 150); + }); + + // Änderungen tracken bei Input + table.addEventListener("input", () => { + hasLocalChanges = true; + }); + + // Paste-Handler + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + + const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); + if (pasteRows.length === 0) return; + + const activeInput = document.activeElement; + let startRow = 0; + let startCol = 0; + + if (activeInput && activeInput.tagName === "INPUT") { + const td = activeInput.closest("td"); + const tr = td?.closest("tr"); + if (tr) { + const allRows = Array.from(tbody.children); + startRow = allRows.indexOf(tr); + if (startRow >= 0) { + const allCells = Array.from(tr.children); + const cellIdx = allCells.indexOf(td); + startCol = cellIdx > 0 ? cellIdx - 1 : 0; + } + } + } + + const keys = [ + "firstName", + "lastName", + "street", + "houseNumber", + "zip", + "city", + "country", + ]; + const currentRows = table._rows; + const newData = []; + for (let i = 0; i < want; i++) { + newData.push( + currentRows[i] + ? { ...currentRows[i] } + : { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }, + ); + } + + let pastedCells = 0; + pasteRows.forEach((rowText, rowOffset) => { + const targetRow = startRow + rowOffset; + if (targetRow >= want) return; + + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetCol = startCol + colOffset; + if (targetCol >= keys.length) return; + const key = keys[targetCol]; + newData[targetRow][key] = cellValue; + pastedCells++; + }); + }); + + if (pastedCells > 0) { + dispatch({ type: "SET_RECIPIENT_ROWS", rows: newData }); + } + }); + + return table; + } + + // Freie Adresstabelle (5 Zeilen) + function buildFreeTable(currentState) { + const rows = Array.isArray(currentState.freeAddressRows) + ? currentState.freeAddressRows + : []; + + const table = h( + "table", + { class: "sk-table sk-table-pasteable", tabindex: "0" }, + [], + ); + table.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + h("th", { text: "Zeile 1" }), + h("th", { text: "Zeile 2" }), + h("th", { text: "Zeile 3" }), + h("th", { text: "Zeile 4" }), + h("th", { text: "Land" }), + ]), + ]), + ); + + const tbody = h("tbody", {}, []); + for (let idx = 0; idx < want; idx++) { + const r = rows[idx] || {}; + const cell = (key) => + h("td", {}, [ + h("input", { + class: "sk-input", + type: "text", + value: r[key] || "", + "data-row": idx, + "data-col": key, + "data-sk-focus": `freeaddr.${key}.${idx}`, + oninput: (e) => { + if (!rows[idx]) { + rows[idx] = { + line1: "", + line2: "", + line3: "", + line4: "", + line5: "", + }; + } + rows[idx][key] = e.target.value; + }, + }), + ]); + + tbody.appendChild( + h("tr", {}, [ + h("td", { text: String(idx + 1), class: "sk-table-row-number" }), + cell("line1"), + cell("line2"), + cell("line3"), + cell("line4"), + cell("line5"), + ]), + ); + } + table.appendChild(tbody); + + // Focusout-Handler mit Debounce um Flackern zu vermeiden + let focusoutTimer = null; + let hasLocalChanges = false; // Track ob lokale Änderungen gemacht wurden + table.addEventListener("focusout", (e) => { + if (focusoutTimer) clearTimeout(focusoutTimer); + focusoutTimer = setTimeout(() => { + const newFocus = document.activeElement; + if (!table.contains(newFocus) && hasLocalChanges) { + // Nur dispatchen wenn lokale Änderungen gemacht wurden + dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: [...rows] }); + hasLocalChanges = false; + } + }, 150); + }); + + // Änderungen tracken bei Input + table.addEventListener("input", () => { + hasLocalChanges = true; + }); + + // Paste-Handler + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + + const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); + if (pasteRows.length === 0) return; + + const activeInput = document.activeElement; + let startRow = 0; + let startCol = 0; + + if (activeInput && activeInput.tagName === "INPUT") { + const td = activeInput.closest("td"); + const tr = td?.closest("tr"); + if (tr) { + const allRows = Array.from(tbody.children); + startRow = allRows.indexOf(tr); + if (startRow >= 0) { + const allCells = Array.from(tr.children); + const cellIdx = allCells.indexOf(td); + startCol = cellIdx > 0 ? cellIdx - 1 : 0; + } + } + } + + const keys = ["line1", "line2", "line3", "line4", "line5"]; + const newData = []; + for (let i = 0; i < want; i++) { + newData.push( + rows[i] + ? { ...rows[i] } + : { line1: "", line2: "", line3: "", line4: "", line5: "" }, + ); + } + + let pastedCells = 0; + pasteRows.forEach((rowText, rowOffset) => { + const targetRow = startRow + rowOffset; + if (targetRow >= want) return; + + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetCol = startCol + colOffset; + if (targetCol >= keys.length) return; + newData[targetRow][keys[targetCol]] = cellValue; + pastedCells++; + }); + }); + + if (pastedCells > 0) { + dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: newData }); + } + }); + + return table; + } + + // Modal öffnen + function openRecipientsModal() { + if (addressMode === "free") { + createTableModal(dispatch, { + title: "Freie Adresse", + columns: [ + { key: "line1", label: "Zeile 1" }, + { key: "line2", label: "Zeile 2" }, + { key: "line3", label: "Zeile 3" }, + { key: "line4", label: "Zeile 4" }, + { key: "line5", label: "Land" }, + ], + getData: (state) => { + const rows = Array.isArray(state.freeAddressRows) + ? state.freeAddressRows + : []; + const normalized = []; + for (let i = 0; i < want; i++) { + normalized.push( + rows[i] || { + line1: "", + line2: "", + line3: "", + line4: "", + line5: "", + }, + ); + } + return normalized; + }, + setData: (dispatch, data) => { + dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: data }); + }, + rowCount: () => want, + exportFilename: "freie_adressen.csv", + helpText: "Geben Sie bis zu 5 Zeilen pro Empfänger ein.", + getStateFunc: () => currentGlobalState, + }); + } else { + createTableModal(dispatch, { + title: "Empfänger-Daten", + columns: [ + { key: "firstName", label: "Vorname" }, + { key: "lastName", label: "Nachname" }, + { key: "street", label: "Straße" }, + { key: "houseNumber", label: "Nr." }, + { key: "zip", label: "PLZ" }, + { key: "city", label: "Stadt" }, + { + key: "country", + label: "Land", + placeholder: isDirectShipping ? "Deutschland" : "", + }, + ], + getData: (state) => { + const rows = Array.isArray(state.recipientRows) + ? state.recipientRows + : []; + const normalized = []; + for (let i = 0; i < want; i++) { + normalized.push( + rows[i] || { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }, + ); + } + return normalized; + }, + setData: (dispatch, data) => { + dispatch({ type: "SET_RECIPIENT_ROWS", rows: data }); + }, + rowCount: () => want, + exportFilename: "empfaenger.csv", + helpText: + "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", + getStateFunc: () => currentGlobalState, + }); + } + } + + // Switch-Element für Adressmodus + const addressModeSwitch = h( + "div", + { + class: "sk-address-mode-switch", + style: "display: flex; gap: 8px; margin-bottom: 12px;", + }, + [ + h("button", { + type: "button", + class: + addressMode === "classic" + ? "sk-btn sk-btn-primary" + : "sk-btn sk-btn-outline", + text: "Klassische Adresse", + onclick: () => dispatch({ type: "SET_ADDRESS_MODE", mode: "classic" }), + }), + h("button", { + type: "button", + class: + addressMode === "free" + ? "sk-btn sk-btn-primary" + : "sk-btn sk-btn-outline", + text: "Freie Adresse (bis zu 5 Zeilen)", + onclick: () => dispatch({ type: "SET_ADDRESS_MODE", mode: "free" }), + }), + ], + ); + + const tableContent = + addressMode === "free" ? buildFreeTable(state) : buildClassicTable(state); + + // Porto-Hinweis entfernt + const shippingNotice = null; + + const cardContent = [ + addressModeSwitch, + h("div", { + class: "sk-help", + text: "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", + }), + h("button", { + type: "button", + class: "sk-btn", + text: "Tabelle maximieren", + onclick: openRecipientsModal, + }), + h("div", { class: "sk-table-wrapper" }, [tableContent]), + ]; + + if (shippingNotice) { + cardContent.push(shippingNotice); + } + + return renderCard( + "Empfänger-Daten", + addressMode === "free" + ? "Geben Sie bis zu 5 Zeilen pro Empfänger ein" + : "Pflicht: Alle Felder müssen ausgefüllt werden", + cardContent, + ); +} + +// Helper: Kombinierte Platzhalter Tabelle (read-only + editierbar) +function renderCombinedPlaceholderTable( + state, + dispatch, + readOnlyPlaceholders, + editablePlaceholders, + title, +) { + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + + const cleanReadOnly = readOnlyPlaceholders + .map((p) => normalizePlaceholderName(p)) + .filter((p) => !!p); + + const cleanEditable = editablePlaceholders + .map((p) => normalizePlaceholderName(p)) + .filter((p) => !!p); + + const allPlaceholders = [...cleanReadOnly, ...cleanEditable]; + if (allPlaceholders.length === 0) return null; + + // Vorschau-Tabelle bauen + function buildPreviewTable(currentState) { + const table = h( + "table", + { + class: "sk-table sk-table-pasteable", + tabindex: "0", + }, + [], + ); + + // Lokaler Cache für Platzhalter-Werte (kein Re-Render bei Eingabe) + const localValues = {}; + for (const name of cleanEditable) { + localValues[name] = [...(currentState.placeholderValues?.[name] || [])]; + while (localValues[name].length < want) { + localValues[name].push(""); + } + } + + let hasLocalChanges = false; + let autoSaveInterval = null; + + // Funktion zum Speichern der lokalen Änderungen + const saveLocalChanges = () => { + if (hasLocalChanges) { + dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); + hasLocalChanges = false; + } + }; + + // Bei globaler Registry registrieren (für flush vor Navigation) + const unregisterSave = registerTableSave(saveLocalChanges); + + // Auto-Save alle 60 Sekunden + autoSaveInterval = setInterval(saveLocalChanges, 60000); + + // Cleanup-Funktion für Interval und Registry + table._cleanup = () => { + if (autoSaveInterval) clearInterval(autoSaveInterval); + unregisterSave(); + }; + + // Header + table.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + ...allPlaceholders.map((p) => { + const isReadOnly = cleanReadOnly.includes(p); + return h("th", { + text: `[[${p}]]`, + class: isReadOnly ? "sk-table-readonly-header" : "", + }); + }), + ]), + ]), + ); + + // Body + const tbody = h("tbody", {}, []); + for (let row = 0; row < want; row++) { + const tr = h("tr", {}, [ + h("td", { text: String(row + 1), class: "sk-table-row-number" }), + ]); + + for (const name of allPlaceholders) { + const isReadOnly = cleanReadOnly.includes(name); + + let val = ""; + // Bei Empfängerdaten: vorname, name, ort aus recipientRows holen + if ( + currentState.answers.envelopeMode === "recipientData" && + (name === "vorname" || name === "name" || name === "ort") + ) { + const recipients = currentState.recipientRows || []; + const recipient = recipients[row] || {}; + if (name === "vorname") val = recipient.firstName || ""; + else if (name === "name") val = recipient.lastName || ""; + else if (name === "ort") val = recipient.city || ""; + } else if (isReadOnly) { + val = (currentState.placeholderValues?.[name] || [])[row] || ""; + } else { + val = localValues[name][row] || ""; + } + + tr.appendChild( + h("td", { class: isReadOnly ? "sk-table-readonly-cell" : "" }, [ + h("input", { + class: "sk-input", + type: "text", + value: val, + readonly: isReadOnly, + "data-row": row, + "data-col": name, + "data-sk-focus": `placeholder.${name}.${row}`, + oninput: (e) => { + if (!isReadOnly) { + // Nur lokal speichern - kein Re-Render! + localValues[name][row] = e.target.value; + hasLocalChanges = true; + } + }, + }), + ]), + ); + } + tbody.appendChild(tr); + } + table.appendChild(tbody); + + // Focusout-Handler: Speichern wenn Fokus die Tabelle verlässt + let focusoutTimer = null; + table.addEventListener("focusout", (e) => { + if (focusoutTimer) clearTimeout(focusoutTimer); + focusoutTimer = setTimeout(() => { + const newFocus = document.activeElement; + if (!table.contains(newFocus)) { + saveLocalChanges(); + } + }, 150); + }); + + // Paste-Handler für Excel-Daten + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + + const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); + if (pasteRows.length === 0) return; + + // Startposition finden + const activeInput = document.activeElement; + let startRow = 0; + let startCol = 0; + + if (activeInput && activeInput.tagName === "INPUT") { + const td = activeInput.closest("td"); + const tr = td?.closest("tr"); + if (tr) { + const allRows = Array.from(tbody.children); + startRow = allRows.indexOf(tr); + if (startRow >= 0) { + const allCells = Array.from(tr.children); + const cellIdx = allCells.indexOf(td); + // cellIdx 0 ist die Nummer-Spalte, daher -1 für die Daten-Spalten + startCol = cellIdx > 0 ? cellIdx - 1 : 0; + } + } + } + + // Alle Änderungen in localValues sammeln und Inputs aktualisieren + let pastedCells = 0; + pasteRows.forEach((rowText, rowOffset) => { + const targetRow = startRow + rowOffset; + if (targetRow >= want) return; + + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetCol = startCol + colOffset; + if (targetCol >= allPlaceholders.length) return; + + const name = allPlaceholders[targetCol]; + const isReadOnly = cleanReadOnly.includes(name); + + // Nur editierbare Spalten aktualisieren + if (!isReadOnly) { + localValues[name][targetRow] = cellValue; + // Input-Element direkt aktualisieren + const input = table.querySelector( + `input[data-row="${targetRow}"][data-col="${name}"]`, + ); + if (input) input.value = cellValue; + pastedCells++; + } + }); + }); + + // Nach Paste: Einmal speichern + if (pastedCells > 0) { + hasLocalChanges = true; + saveLocalChanges(); + } + }); + + return table; + } + + // Modal mit generischer Funktion öffnen + function openCombinedModal() { + createTableModal(dispatch, { + title: title, + columns: allPlaceholders.map((name) => ({ + key: name, + label: `[[${name}]]`, + readOnly: cleanReadOnly.includes(name), + })), + getData: (state) => { + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + const result = []; + for (let row = 0; row < want; row++) { + const rowData = {}; + for (const name of allPlaceholders) { + // Bei Empfängerdaten: vorname, name, ort aus recipientRows holen + if ( + state.answers.envelopeMode === "recipientData" && + (name === "vorname" || name === "name" || name === "ort") + ) { + const recipients = state.recipientRows || []; + const recipient = recipients[row] || {}; + if (name === "vorname") rowData[name] = recipient.firstName || ""; + else if (name === "name") + rowData[name] = recipient.lastName || ""; + else if (name === "ort") rowData[name] = recipient.city || ""; + } else { + rowData[name] = + (state.placeholderValues?.[name] || [])[row] || ""; + } + } + result.push(rowData); + } + return result; + }, + setData: (dispatch, data) => { + // Nur editierbare Felder updaten + data.forEach((rowData, rowIdx) => { + cleanEditable.forEach((name) => { + dispatch({ + type: "SET_PLACEHOLDER_VALUE", + name, + row: rowIdx, + value: rowData[name] || "", + }); + }); + }); + }, + rowCount: (state) => { + const qty = Number(state.answers.quantity || 0); + return qty > 0 ? qty : 0; + }, + exportFilename: "platzhalter.csv", + infoText: + "Bitte erstellen Sie neue Platzhalter über die Weboberfläche. Anschließend können Sie auch diese importieren.", + helpText: + "Nutzen Sie [[platzhalter]] zur Individualisierung im Text. Vorhandene Platzhalter (grau hinterlegt) kommen aus dem Schritt Umschlag, neue können Sie selbst definieren.", + getStateFunc: () => currentGlobalState, + }); + } + + return renderCard(title, null, [ + h("button", { + type: "button", + class: "sk-btn", + text: "Tabelle maximieren", + onclick: openCombinedModal, + }), + h("div", { class: "sk-table-wrapper" }, [buildPreviewTable(state)]), + (() => { + const helpUrl = + window.SkriftConfigurator?.settings?.font_sample + ?.placeholder_help_url || ""; + const elements = [ + "Nutzen Sie ", + h("strong", { text: "[[platzhalter]]" }), + " zur Individualisierung im Text. Vorhandene Platzhalter (grau hinterlegt) kommen aus dem Schritt Umschlag, neue können Sie selbst definieren.", + ]; + if (helpUrl) { + elements.push(" "); + elements.push( + h("a", { + href: helpUrl, + target: "_blank", + rel: "noopener noreferrer", + style: "color: #0073aa; text-decoration: underline;", + text: "Mehr erfahren →", + }), + ); + } + return h( + "div", + { class: "sk-help", style: "margin-top: 12px;" }, + elements, + ); + })(), + ]); +} + +// Helper: Platzhalter Tabelle (mit Maximieren-Button und Modal) +function renderPlaceholderTable( + state, + dispatch, + placeholderNames, + title, + readOnly = false, + helpText = null, +) { + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + + const clean = placeholderNames + .map((p) => normalizePlaceholderName(p)) + .filter((p) => !!p); + if (clean.length === 0) return null; + + // Vorschau-Tabelle bauen + function buildPreviewTable(currentState) { + const table = h( + "table", + { + class: "sk-table sk-table-pasteable", + tabindex: "0", + }, + [], + ); + + table.appendChild( + h("thead", {}, [ + h("tr", {}, [ + h("th", { text: "Nr.", class: "sk-table-row-number" }), + ...clean.map((p) => h("th", { text: `[[${p}]]` })), + ]), + ]), + ); + + // Lokaler Cache für Platzhalter-Werte (wird bei focusout synchronisiert) + const localValues = {}; + for (const name of clean) { + localValues[name] = [...(currentState.placeholderValues?.[name] || [])]; + // Array auf richtige Länge bringen + while (localValues[name].length < want) { + localValues[name].push(""); + } + } + + const tbody = h("tbody", {}, []); + for (let row = 0; row < want; row++) { + const tr = h("tr", {}, [ + h("td", { text: String(row + 1), class: "sk-table-row-number" }), + ]); + for (const name of clean) { + const val = localValues[name][row] || ""; + tr.appendChild( + h("td", {}, [ + h("input", { + class: "sk-input", + type: "text", + value: val, + "data-row": row, + "data-col": name, + "data-sk-focus": `placeholder.${name}.${row}`, + oninput: (e) => { + // Wert nur lokal speichern (kein Re-Render) + localValues[name][row] = e.target.value; + }, + }), + ]), + ); + } + tbody.appendChild(tr); + } + table.appendChild(tbody); + + // Focusout-Handler mit Debounce - analog zur Empfängertabelle + let focusoutTimer = null; + let hasLocalChanges = false; // Track ob lokale Änderungen gemacht wurden + table.addEventListener("focusout", (e) => { + if (focusoutTimer) clearTimeout(focusoutTimer); + focusoutTimer = setTimeout(() => { + const newFocus = document.activeElement; + if (!table.contains(newFocus) && hasLocalChanges) { + // Nur dispatchen wenn lokale Änderungen gemacht wurden + dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); + hasLocalChanges = false; + } + }, 150); + }); + + // Änderungen tracken bei Input + table.addEventListener("input", () => { + hasLocalChanges = true; + }); + + // Paste-Handler für Excel-Daten - analog zur Empfängertabelle + table.addEventListener("paste", (e) => { + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + if (!text) return; + + const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); + if (pasteRows.length === 0) return; + + // Startposition finden + const activeInput = document.activeElement; + let startRow = 0; + let startCol = 0; + + if (activeInput && activeInput.tagName === "INPUT") { + const td = activeInput.closest("td"); + const tr = td?.closest("tr"); + if (tr) { + const allRows = Array.from(tbody.children); + startRow = allRows.indexOf(tr); + if (startRow >= 0) { + const allCells = Array.from(tr.children); + const cellIdx = allCells.indexOf(td); + startCol = cellIdx > 0 ? cellIdx - 1 : 0; + } + } + } + + // Alle Änderungen in localValues sammeln + let pastedCells = 0; + pasteRows.forEach((rowText, rowOffset) => { + const targetRow = startRow + rowOffset; + if (targetRow >= want) return; + + const cells = rowText.split("\t"); + cells.forEach((cellValue, colOffset) => { + const targetCol = startCol + colOffset; + if (targetCol >= clean.length) return; + + const name = clean[targetCol]; + localValues[name][targetRow] = cellValue; + pastedCells++; + }); + }); + + // Einmal dispatch für alle Änderungen + if (pastedCells > 0) { + dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); + } + }); + + return table; + } + + // Modal mit generischer Funktion öffnen + function openPlaceholderModal() { + createTableModal(dispatch, { + title: title, + columns: clean.map((name) => ({ + key: name, + label: `[[${name}]]`, + })), + getData: (state) => { + const qty = Number(state.answers.quantity || 0); + const want = qty > 0 ? qty : 0; + const result = []; + for (let row = 0; row < want; row++) { + const rowData = {}; + clean.forEach((name) => { + rowData[name] = (state.placeholderValues?.[name] || [])[row] || ""; + }); + result.push(rowData); + } + return result; + }, + setData: (dispatch, data) => { + // Daten in einzelne SET_PLACEHOLDER_VALUE Aktionen umwandeln + data.forEach((rowData, rowIdx) => { + clean.forEach((name) => { + dispatch({ + type: "SET_PLACEHOLDER_VALUE", + name, + row: rowIdx, + value: rowData[name] || "", + }); + }); + }); + }, + rowCount: (state) => { + const qty = Number(state.answers.quantity || 0); + return qty > 0 ? qty : 0; + }, + exportFilename: "platzhalter.csv", + infoText: + "Bitte erstellen Sie neue Platzhalter über die Weboberfläche. Anschließend können Sie auch diese importieren.", + helpText: + "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", + getStateFunc: () => currentGlobalState, + }); + } + + const defaultHelpText = + "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus."; + + return renderCard(title, null, [ + h("button", { + type: "button", + class: "sk-btn", + text: "Tabelle maximieren", + onclick: openPlaceholderModal, + }), + h("div", { class: "sk-table-wrapper" }, [buildPreviewTable(state)]), + h("div", { class: "sk-help", style: "margin-top: 12px;" }, [ + helpText || defaultHelpText, + ]), + ]); +} + +// Preview Renderer +function renderPreview(dom, state, dispatch) { + // Prüfe ob wir eine existierende Vorschau-Box haben + const existingPreviewCard = dom.preview.querySelector(".sk-preview-card"); + const existingPreviewBox = + existingPreviewCard?.querySelector(".sk-preview-box"); + const hasActivePreview = + existingPreviewBox && + !existingPreviewBox.querySelector(".sk-preview-placeholder"); + + // Prüfe ob Step gespeichert ist und ob er sich geändert hat + const lastStep = dom.preview.dataset.lastStep; + const currentStep = state.step; + const stepChanged = lastStep && lastStep !== String(currentStep); + + // Bei Step-Wechsel: Immer clearen und Preview-Manager clearen + if (stepChanged) { + clear(dom.preview); + // Preview Manager resetten + if (window.envelopePreviewManager) { + window.envelopePreviewManager.currentBatchPreviews = []; + window.envelopePreviewManager.currentBatchIndex = 0; + window.envelopePreviewManager.previewCount = 0; + } + if (window.contentPreviewManager) { + window.contentPreviewManager.currentBatchPreviews = []; + window.contentPreviewManager.currentBatchIndex = 0; + window.contentPreviewManager.previewCount = 0; + } + } + // Sonst: Nur clearen wenn keine Preview geladen ist + else { + const hasEnvelopePreview = + window.envelopePreviewManager?.currentBatchPreviews?.length > 0; + const hasContentPreview = + window.contentPreviewManager?.currentBatchPreviews?.length > 0; + + if (!hasEnvelopePreview && !hasContentPreview) { + clear(dom.preview); + } + } + + // Step speichern für nächstes Mal + dom.preview.dataset.lastStep = String(currentStep); + + // Show contact card on product, quantity, customer data and review step + // (statt Vorschau bei Produkt und Allgemein) + if ( + state.step === STEPS.PRODUCT || + state.step === STEPS.QUANTITY || + state.step === STEPS.CUSTOMER_DATA || + state.step === STEPS.REVIEW + ) { + clear(dom.preview); // Hier immer clearen, da Contact Card + const contactCard = renderCard("Noch Fragen?", null, [ + h("p", { + style: + "margin: 0 0 20px; font-size: 14px; color: #666; line-height: 1.6;", + text: "Sie haben Fragen zu Ihrer Bestellung oder möchten eine individuelle Beratung? Wir sind gerne für Sie da und helfen Ihnen weiter!", + }), + h( + "div", + { + style: + "display: flex; align-items: center; gap: 12px; margin-bottom: 12px;", + }, + [ + h("svg", { + xmlns: "http://www.w3.org/2000/svg", + width: "20", + height: "20", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: "flex-shrink: 0; color: #0073aa;", + innerHTML: + '', + }), + h("a", { + href: "tel:+4968722153", + style: + "font-size: 15px; color: #333; text-decoration: none; font-weight: 500;", + text: "+49 (0)6872 2153", + }), + ], + ), + h("div", { style: "display: flex; align-items: center; gap: 12px;" }, [ + h("svg", { + xmlns: "http://www.w3.org/2000/svg", + width: "20", + height: "20", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + style: "flex-shrink: 0; color: #0073aa;", + innerHTML: + '', + }), + h("a", { + href: "mailto:hello@skrift.de", + style: + "font-size: 15px; color: #333; text-decoration: none; font-weight: 500;", + text: "hello@skrift.de", + }), + ]), + ]); + + dom.preview.appendChild(contactCard); + // Auch für Contact Card mobile synchronisieren + syncMobilePreview(dom); + return; + } + + const envType = calcEffectiveEnvelopeType(state); + const previewTitle = state?.ctx?.product?.label + ? `Vorschau: ${state.ctx.product.label}` + : "Vorschau"; + + // Format-Informationen ermitteln + const fmt = state.answers.format; + const productFormat = + fmt === "a6p" + ? "A6 Hochformat" + : fmt === "a6l" + ? "A6 Querformat" + : fmt === "a4" + ? "A4" + : fmt?.toUpperCase() || "A4"; + const envelopeFormat = + envType === "dinlang" ? "DIN lang" : envType === "c6" ? "C6" : ""; + + // Formatinformationen für Umschlag-Step + let formatInfo = ""; + if (state.step === STEPS.ENVELOPE && envelopeFormat) { + formatInfo = `Umschlagformat: ${envelopeFormat}`; + } + // Formatinformationen für Content-Step + else if (state.step === STEPS.CONTENT) { + formatInfo = `Schriftstückformat: ${productFormat}`; + } + + const content = [ + // Header mit Titel und Navigation + h( + "div", + { + class: "sk-preview-header", + style: + "display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;", + }, + [ + h( + "div", + { style: "flex: 1;" }, + [ + h("div", { class: "sk-preview-title", text: previewTitle }), + formatInfo + ? h("div", { + class: "sk-preview-sub", + text: formatInfo, + }) + : null, + ].filter(Boolean), + ), + h("div", { class: "sk-preview-navigation-container" }), + ], + ), + h("div", { class: "sk-preview-box" }, [ + h("div", { + class: "sk-preview-placeholder", + text: "Hier können Sie sich die Vorschau Ihres Produktes anzeigen lassen.", + }), + ]), + // Status Anzeige direkt unter dem Dokument + h("div", { + class: "sk-preview-status", + style: + "margin-top: 10px; font-size: 14px; color: #666; text-align: center;", + }), + ].filter(Boolean); + + // Vorschau-Button für Umschlag (nur im Envelope-Step) + if (state.step === STEPS.ENVELOPE) { + const canGenerateEnvelopePreview = + state.answers.shippingMode === "direct" || + (state.answers.shippingMode === "bulk" && + state.answers.envelope === true && + (state.answers.envelopeMode === "recipientData" || + state.answers.envelopeMode === "customText")); + + if (canGenerateEnvelopePreview) { + const manager = window.envelopePreviewManager; + const requestsRemaining = manager?.requestsRemaining || 10; + const maxRequests = manager?.maxRequests || 10; + + // Request Counter + content.push( + h("div", { + class: "sk-preview-request-counter", + style: + "margin-top: 10px; padding: 8px; background: #e3f2fd; border: 1px solid #2196f3; border-radius: 4px; color: #1565c0; font-size: 14px; text-align: center; font-weight: 500;", + text: `Verbleibende Vorschau-Anfragen: ${requestsRemaining}/${maxRequests}`, + }), + ); + + // Button + content.push( + h("button", { + type: "button", + class: "sk-btn sk-btn-secondary sk-preview-generate-btn", + style: "width: 100%; margin-top: 10px;", + text: "Umschlag Vorschau generieren", + onclick: async () => { + if (window.envelopePreviewManager) { + // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure + const currentState = window.currentGlobalState || state; + await window.envelopePreviewManager.generatePreviews( + currentState, + dom.preview, + true, + ); + } + }, + }), + ); + } + } + + // Vorschau-Button für Schriftstück (nur im Content-Step) + if (state.step === STEPS.CONTENT) { + const canGenerateContentPreview = + state.answers.contentCreateMode === "self"; + + if (canGenerateContentPreview) { + const manager = window.contentPreviewManager; + const requestsRemaining = manager?.requestsRemaining || 10; + const maxRequests = manager?.maxRequests || 10; + + // Request Counter + content.push( + h("div", { + class: "sk-preview-request-counter", + style: + "margin-top: 10px; padding: 8px; background: #e3f2fd; border: 1px solid #2196f3; border-radius: 4px; color: #1565c0; font-size: 14px; text-align: center; font-weight: 500;", + text: `Verbleibende Vorschau-Anfragen: ${requestsRemaining}/${maxRequests}`, + }), + ); + + // Button + content.push( + h("button", { + type: "button", + class: "sk-btn sk-btn-secondary sk-preview-generate-btn", + style: "width: 100%; margin-top: 10px;", + text: "Vorschau generieren", + onclick: async () => { + if (window.contentPreviewManager) { + // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure + const currentState = window.currentGlobalState || state; + const result = + await window.contentPreviewManager.generatePreviews( + currentState, + dom.preview, + false, + ); + + // Overflow-Warnung anzeigen wenn nötig + const formContainer = document.getElementById("sk-form"); + if (result && result.hasOverflow && formContainer) { + showOverflowWarning(result.overflowFiles, formContainer); + } else if (formContainer) { + hideOverflowWarning(formContainer); + } + } + }, + }), + ); + } + } + + // Nur hinzufügen wenn keine Preview-Card existiert + const existingCard = dom.preview.querySelector(".sk-preview-card"); + if (!existingCard) { + dom.preview.appendChild(h("div", { class: "sk-preview-card" }, content)); + } else { + // Preview-Card existiert bereits - nur Button und Counter aktualisieren + const existingCounter = existingCard.querySelector( + ".sk-preview-request-counter", + ); + const newCounter = content.find( + (el) => el && el.class === "sk-preview-request-counter", + ); + if (existingCounter && newCounter) { + existingCounter.textContent = newCounter.text; + } + } + + // Mobile Preview synchronisieren + syncMobilePreview(dom); +} + +/** + * Synchronisiert den Mobile Preview Container mit dem Desktop Preview + * Klont den Inhalt und passt Event-Handler für die Buttons an + * + * Mobile Layout: + * - previewMobile: Nur echte Vorschau-Inhalte (vor dem Weiter-Button) + * - contactMobile: Nur Contact Card "Noch Fragen?" (nach dem Weiter-Button) + */ +function syncMobilePreview(dom) { + // Prüfe ob Contact Card im Desktop-Preview ist + const hasContactCard = + dom.preview.querySelector(".sk-card-head h3")?.textContent === + "Noch Fragen?"; + + // Contact Card Mobile synchronisieren + if (dom.contactMobile) { + clear(dom.contactMobile); + if (hasContactCard) { + // Contact Card in den Mobile-Contact-Container klonen + dom.contactMobile.innerHTML = dom.preview.innerHTML; + } + } + + // Preview Mobile synchronisieren - nur wenn KEINE Contact Card + if (dom.previewMobile) { + clear(dom.previewMobile); + + // Nur echte Vorschau-Inhalte klonen (nicht Contact Card) + if (!hasContactCard) { + const desktopContent = dom.preview.innerHTML; + if (desktopContent) { + dom.previewMobile.innerHTML = desktopContent; + + // Event-Handler für Buttons im Mobile-Container neu binden + bindMobilePreviewHandlers(dom); + } + } + } +} + +/** + * Bindet Event-Handler für den Mobile Preview Container + */ +function bindMobilePreviewHandlers(dom) { + if (!dom.previewMobile) return; + + const mobileGenerateBtn = dom.previewMobile.querySelector( + ".sk-preview-generate-btn", + ); + if (mobileGenerateBtn) { + mobileGenerateBtn.onclick = async () => { + const currentState = window.currentGlobalState; + if (!currentState) return; + + // Bestimme welcher Manager verwendet werden soll basierend auf dem aktuellen Step + const isEnvelope = currentState.step === 2; // STEPS.ENVELOPE + const manager = isEnvelope + ? window.envelopePreviewManager + : window.contentPreviewManager; + + if (manager) { + // Preview in BEIDEN Containern aktualisieren + const result = await manager.generatePreviews( + currentState, + dom.preview, + isEnvelope, + ); + // Nach Generierung nochmal synchronisieren + syncMobilePreview(dom); + + // Overflow-Warnung bei Briefen (nicht Umschläge) + if (!isEnvelope) { + const formContainer = document.getElementById("sk-form"); + if (result && result.hasOverflow && formContainer) { + showOverflowWarning(result.overflowFiles, formContainer); + } else if (formContainer) { + hideOverflowWarning(formContainer); + } + } + } + }; + } + + // Navigation Buttons im Mobile-Container + const mobilePrevBtn = dom.previewMobile.querySelector(".sk-preview-nav-prev"); + const mobileNextBtn = dom.previewMobile.querySelector(".sk-preview-nav-next"); + + if (mobilePrevBtn) { + mobilePrevBtn.onclick = () => { + // Finde und klicke den Desktop-Button + const desktopBtn = dom.preview.querySelector(".sk-preview-nav-prev"); + if (desktopBtn) desktopBtn.click(); + // Nach Navigation synchronisieren + setTimeout(() => syncMobilePreview(dom), 50); + }; + } + + if (mobileNextBtn) { + mobileNextBtn.onclick = () => { + // Finde und klicke den Desktop-Button + const desktopBtn = dom.preview.querySelector(".sk-preview-nav-next"); + if (desktopBtn) desktopBtn.click(); + // Nach Navigation synchronisieren + setTimeout(() => syncMobilePreview(dom), 50); + }; + } + + // Klick auf Preview-Box für Modal + const mobilePreviewBox = dom.previewMobile.querySelector(".sk-preview-box"); + if (mobilePreviewBox) { + mobilePreviewBox.onclick = () => { + // Desktop-Preview-Box klicken, um Modal zu öffnen + const desktopBox = dom.preview.querySelector(".sk-preview-box"); + if (desktopBox) desktopBox.click(); + }; + } +} + +// Main Render Function +export function render({ state, dom, dispatch }) { + // Globalen State aktualisieren für Modals und PreviewManager + currentGlobalState = state; + window.currentGlobalState = state; + + // noPrice-Modus: CSS-Klasse auf Root-Element setzen + const root = dom.form?.closest(".sk-configurator"); + if (root) { + if (state.noPrice) { + root.classList.add("sk-no-price"); + } else { + root.classList.remove("sk-no-price"); + } + } + + // Periodische Speicherung starten (nur einmal) + startPersistInterval(); + + // Hydration VOR dem Rendern prüfen - wenn hydriert wird, + // wird ein neuer Render mit dem hydrierten State ausgelöst, + // daher diesen Render-Durchlauf abbrechen + const didHydrate = hydrateIfPossible(state, dispatch); + if (didHydrate) return; + + if (dom.stepper) renderStepper(dom.stepper, state, dispatch); + renderTopbar(dom, state); + + const focusSnapshot = captureFocus(dom); + + clear(dom.form); + + let view = null; + switch (state.step) { + case STEPS.PRODUCT: + view = renderProductStep(state, dispatch); + break; + case STEPS.QUANTITY: + view = renderQuantityStep(state, dispatch); + break; + case STEPS.ENVELOPE: + view = renderEnvelopeStep(state, dispatch); + break; + case STEPS.CONTENT: + view = renderContentStep(state, dispatch); + break; + case STEPS.CUSTOMER_DATA: + view = renderCustomerDataStep(state, dispatch); + break; + case STEPS.REVIEW: + view = renderReviewStep(state, dispatch); + break; + } + + if (view) dom.form.appendChild(view); + + renderPreview(dom, state, dispatch); + + restoreFocus(dom, focusSnapshot); + + dom.prev.disabled = state.history.length === 0; + dom.next.disabled = !validateStep(state) || state.step === STEPS.REVIEW; + dom.next.textContent = + state.step === STEPS.CUSTOMER_DATA ? "Weiter zur Prüfung" : "Weiter"; + + writePersisted(state); +} diff --git a/skrift-configurator/assets/js/configurator-utils.js b/skrift-configurator/assets/js/configurator-utils.js new file mode 100644 index 0000000..bac3836 --- /dev/null +++ b/skrift-configurator/assets/js/configurator-utils.js @@ -0,0 +1,96 @@ +/** + * Gemeinsame Utility-Funktionen für Skrift Konfigurator + */ + +/** + * Bereitet Platzhalter für einen bestimmten Index vor + * Wird von PreviewManager und Backend-Integration verwendet + */ +export function preparePlaceholdersForIndex(state, index) { + const placeholders = {}; + + // Prüfen ob strukturierte Empfängerdaten (klassische Adresse) im aktuellen Flow verwendet werden: + // - Adressmodus muss 'classic' sein (bei 'free' gibt es keine Felder wie vorname, name etc.) + // - UND: Direktversand ODER Kuvert mit Empfängeradresse + const isClassicAddress = (state.addressMode || 'classic') === 'classic'; + const needsRecipientData = isClassicAddress && ( + state.answers?.shippingMode === 'direct' || + (state.answers?.envelope === true && state.answers?.envelopeMode === 'recipientData') + ); + + // Empfänger-Feldnamen, die aus recipientRows kommen können + const recipientFields = ['vorname', 'name', 'ort', 'strasse', 'hausnummer', 'plz', 'land']; + + // Platzhalter aus placeholderValues extrahieren + if (state.placeholderValues) { + for (const [name, values] of Object.entries(state.placeholderValues)) { + // Empfängerfelder nur überspringen wenn sie aus recipientRows kommen + if (needsRecipientData && recipientFields.includes(name)) { + continue; + } + if (Array.isArray(values) && values.length > index) { + placeholders[name] = values[index]; + } + } + } + + // Empfängerdaten aus recipientRows hinzufügen (nur wenn benötigt) + if (needsRecipientData && Array.isArray(state.recipientRows) && state.recipientRows.length > index) { + const recipient = state.recipientRows[index]; + placeholders['vorname'] = recipient.firstName || ''; + placeholders['name'] = recipient.lastName || ''; + placeholders['ort'] = recipient.city || ''; + placeholders['strasse'] = recipient.street || ''; + placeholders['hausnummer'] = recipient.houseNumber || ''; + placeholders['plz'] = recipient.zip || ''; + placeholders['land'] = recipient.country || ''; + } + + return placeholders; +} + +/** + * Validiert Empfängerzeilen (gemeinsame Logik für klassische und freie Adressen) + */ +export function validateRecipientRows(state, requiredCount) { + const addressMode = state.addressMode || 'classic'; + + if (addressMode === 'free') { + // Freie Adresse: mindestens Zeile 1 muss ausgefüllt sein + const rows = state.freeAddressRows || []; + if (rows.length !== requiredCount) return false; + + for (const r of rows) { + if (!r) return false; + if (!String(r.line1 || "").trim()) return false; + } + } else { + // Klassische Adresse + const rows = state.recipientRows || []; + if (rows.length !== requiredCount) return false; + + const required = [ + "firstName", + "lastName", + "street", + "houseNumber", + "zip", + "city", + "country", + ]; + + for (const r of rows) { + if (!r) return false; + for (const k of required) { + if (!String(r[k] || "").trim()) return false; + } + } + } + + return true; +} + +export default { + preparePlaceholdersForIndex, + validateRecipientRows, +}; diff --git a/skrift-configurator/assets/js/price-calculator.js b/skrift-configurator/assets/js/price-calculator.js new file mode 100644 index 0000000..45cbe75 --- /dev/null +++ b/skrift-configurator/assets/js/price-calculator.js @@ -0,0 +1,1145 @@ +/** + * Skrift Preisrechner + * Einfacher Preisrechner mit 2-Step-Flow: Produktauswahl -> Details & Berechnung + * Nutzt die gleiche Preislogik und Produktdefinitionen wie der Konfigurator + */ + +import { + formatEUR, +} from "./configurator-pricing.js?ver=0.3.0"; + +import { + listProducts, + getAvailableProductsForCustomerType, + isFollowups as checkIsFollowups, + supportsMotif as checkSupportsMotif, +} from "./configurator-state.js?ver=0.3.0"; + +// ========== KONSTANTEN ========== + +const STEPS = { + PRODUCT: 0, + CALCULATOR: 1, +}; + +// ========== STATE MANAGEMENT ========== + +function createInitialState() { + return { + step: STEPS.PRODUCT, + customerType: null, // 'business' | 'private' + product: null, + // Calculator-Felder + quantity: 100, + format: 'a6h', // a4, a6h (a6p), a6q (a6l) + shippingMode: 'direct', // direct, bulk + envelope: true, // Bei Sammelversand: Kuvert ja/nein + envelopeMode: 'recipientData', // recipientData, customText, none (Beschriftungsart) + followupYearlyVolume: '50-199', + followupCreateMode: 'manual', // auto, manual + // Motiv (nur für Postkarten/Einladungen mit A6) + motifNeed: false, + motifSource: null, // upload, design + // Inhalt + contentCreateMode: 'self', // self, textservice + }; +} + +function reducer(state, action) { + switch (action.type) { + case 'SET_CUSTOMER_TYPE': + return { + ...state, + customerType: action.customerType, + product: null, // Reset Produkt bei Kundentyp-Wechsel + }; + + case 'SET_PRODUCT': { + // Default-Format setzen + const defaultFormat = action.product.formats?.[0] || 'a4'; + // Default-Menge je nach Kundentyp + const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; + const dynamicPricing = settings.dynamic_pricing || {}; + const isB2B = state.customerType === 'business'; + const normalQty = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + return { + ...state, + product: action.product, + format: defaultFormat, + quantity: normalQty, + step: STEPS.CALCULATOR, + // Reset Motiv bei Produktwechsel + motifNeed: false, + motifSource: null, + }; + } + + case 'SET_QUANTITY': + return { ...state, quantity: action.quantity }; + + case 'SET_FORMAT': + return { + ...state, + format: action.format, + // Motiv zurücksetzen wenn nicht mehr A6 + motifNeed: action.format !== 'a4' ? state.motifNeed : false, + motifSource: action.format !== 'a4' ? state.motifSource : null, + }; + + case 'SET_SHIPPING_MODE': + return { + ...state, + shippingMode: action.shippingMode, + // Bei Direktversand ist Umschlag immer dabei mit Beschriftung + envelope: action.shippingMode === 'direct' ? true : state.envelope, + envelopeMode: action.shippingMode === 'direct' ? 'recipientData' : state.envelopeMode, + }; + + case 'SET_ENVELOPE': + return { + ...state, + envelope: action.envelope, + // Wenn kein Kuvert, dann auch keine Beschriftung + envelopeMode: action.envelope ? state.envelopeMode : 'none', + }; + + case 'SET_ENVELOPE_MODE': + return { ...state, envelopeMode: action.mode }; + + case 'SET_FOLLOWUP_VOLUME': + return { ...state, followupYearlyVolume: action.volume }; + + case 'SET_FOLLOWUP_CREATE_MODE': + return { ...state, followupCreateMode: action.mode }; + + case 'SET_MOTIF_NEED': + return { + ...state, + motifNeed: action.motifNeed, + motifSource: action.motifNeed ? (state.motifSource || 'upload') : null, + }; + + case 'SET_MOTIF_SOURCE': + return { ...state, motifSource: action.source }; + + case 'SET_CONTENT_CREATE_MODE': + return { ...state, contentCreateMode: action.mode }; + + case 'GO_BACK': + return { + ...state, + step: STEPS.PRODUCT, + product: null, + }; + + default: + return state; + } +} + +// ========== PRODUKT-DEFINITIONEN ========== +// Nutzt getAvailableProductsForCustomerType und listProducts aus configurator-state.js +// Diese enthalten die korrekten Formate, supportsMotif, isFollowUp etc. + +// ========== PREIS-BERECHNUNG ========== + +function calculatePrice(state) { + if (!state.product) return null; + + const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; + const prices = settings.prices || {}; + const dynamicPricing = settings.dynamic_pricing || {}; + const isB2B = state.customerType === 'business'; + const taxRate = (prices.tax_rate || 19) / 100; + + // Follow-ups haben spezielle Preislogik + if (state.product.isFollowUp === true) { + return calculateFollowupsPrice(state, prices, taxRate, isB2B); + } + + // Normale Produkte + return calculateStandardPrice(state, prices, dynamicPricing, taxRate, isB2B); +} + +function calculateFollowupsPrice(state, prices, taxRate, isB2B) { + const basePrice = state.product.basePrice; + + // Multiplikator basierend auf Monatsvolumen + const multipliers = { + '5-49': prices.followup_mult_5_49 || 2.0, + '50-199': prices.followup_mult_50_199 || 1.7, + '200-499': prices.followup_mult_200_499 || 1.4, + '500-999': prices.followup_mult_500_999 || 1.2, + '1000+': prices.followup_mult_1000_plus || 1.0, + }; + + const multiplier = multipliers[state.followupYearlyVolume] || 1.7; + + // A4-Aufschlag bei Follow-ups (wie bei anderen Produkten mit supportsMotif) + let formatSurcharge = 0; + if (state.format === 'a4' && state.product.supportsMotif) { + formatSurcharge = parseFloat(prices.a4_upgrade_surcharge) || 0.50; + } + + const pricePerPiece = (basePrice * multiplier) + formatSurcharge; + + // Versandkosten (immer Direktversand bei Follow-ups, immer mit Kuvert + Beschriftung) + const shippingDomestic = parseFloat(prices.shipping_domestic) || 0.95; + const shippingService = parseFloat(prices.shipping_service) || 0.95; + const envelopeBase = parseFloat(prices.envelope_base) || 0.50; + const envelopeLabeling = parseFloat(prices.envelope_labeling) || 0.50; // Immer dabei + + const shippingPerPiece = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; + const totalPerPiece = pricePerPiece + shippingPerPiece; + + // Brutto/Netto + const netPerPiece = totalPerPiece; + const grossPerPiece = totalPerPiece * (1 + taxRate); + + // Einmalige Kosten + const setupCosts = []; + + // API-Anbindung (nur bei automatischer Erstellung) + if (state.followupCreateMode === 'auto') { + const apiConnection = parseFloat(prices.api_connection) || 250.00; + setupCosts.push({ label: 'API-Anbindung', amount: apiConnection }); + } + + // Textservice (auch bei Follow-ups möglich) + if (state.contentCreateMode === 'textservice') { + const textservicePrice = parseFloat(prices.textservice) || 0; + if (textservicePrice > 0) { + setupCosts.push({ label: 'Textservice', amount: textservicePrice }); + } + } + + return { + pricePerPiece: isB2B ? netPerPiece : grossPerPiece, + isGross: !isB2B, + details: { + basePrice: basePrice, + multiplier: multiplier, + volumeTier: state.followupYearlyVolume, + formatSurcharge: formatSurcharge, + productPrice: pricePerPiece, + shippingDomestic: shippingDomestic, + shippingService: shippingService, + envelopeBase: envelopeBase, + envelopeLabeling: envelopeLabeling, + shippingTotal: shippingPerPiece, + netPerPiece: netPerPiece, + grossPerPiece: grossPerPiece, + taxRate: taxRate * 100, + setupCosts: setupCosts, + }, + }; +} + +function calculateStandardPrice(state, prices, dynamicPricing, taxRate, isB2B) { + const basePrice = state.product.basePrice; + const quantity = state.quantity || 1; + + // Dynamischer Multiplikator basierend auf Menge + const normalQty = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + let multiplier = 1.0; + if (quantity < normalQty) { + // Formel: 2 - sqrt(qty / normalQty) + multiplier = 2 - Math.sqrt(quantity / normalQty); + } + + // A4-Aufschlag für Postkarten/Einladungen + let formatSurcharge = 0; + const isPostcardLike = state.product.supportsMotif; + if (state.format === 'a4' && isPostcardLike) { + formatSurcharge = parseFloat(prices.a4_upgrade_surcharge) || 0.50; + } + + const productPrice = (basePrice * multiplier) + formatSurcharge; + + // Versandkosten + let shippingTotal = 0; + let shippingDetails = {}; + + if (state.shippingMode === 'direct') { + // Direktversand: Porto + Service + Kuvert + Beschriftung (immer dabei) + const shippingDomestic = parseFloat(prices.shipping_domestic) || 0.95; + const shippingService = parseFloat(prices.shipping_service) || 0.95; + const envelopeBase = parseFloat(prices.envelope_base) || 0.50; + const envelopeLabeling = parseFloat(prices.envelope_labeling) || 0.50; // Immer bei Direktversand + + shippingTotal = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; + shippingDetails = { + type: 'direct', + porto: shippingDomestic, + service: shippingService, + envelope: envelopeBase, + labeling: envelopeLabeling, + }; + } else { + // Sammelversand: Nur Kuvert + optionale Beschriftung (KEINE anteilige Versandpauschale pro Stück!) + let envelopeCost = 0; + let labelingCost = 0; + if (state.envelope) { + envelopeCost = parseFloat(prices.envelope_base) || 0.50; + // Beschriftung nur wenn envelopeMode nicht 'none' ist + if (state.envelopeMode && state.envelopeMode !== 'none') { + labelingCost = parseFloat(prices.envelope_labeling) || 0.50; + } + } + shippingTotal = envelopeCost + labelingCost; + shippingDetails = { + type: 'bulk', + flatRate: parseFloat(prices.shipping_bulk) || 4.95, // Wird separat als Pauschale angezeigt + envelopeCost: envelopeCost, + labelingCost: labelingCost, + envelopeMode: state.envelopeMode, + }; + } + + const totalPerPiece = productPrice + shippingTotal; + + // Brutto/Netto + const netPerPiece = totalPerPiece; + const grossPerPiece = totalPerPiece * (1 + taxRate); + + // Einmalige Kosten + const setupCosts = []; + + // Motiv-Upload + if (state.motifNeed && state.motifSource === 'upload') { + const motifUploadPrice = parseFloat(prices.motif_upload) || 0.30; + if (motifUploadPrice > 0) { + setupCosts.push({ label: 'Motiv-Upload', amount: motifUploadPrice }); + } + } + + // Motiv-Design + if (state.motifNeed && state.motifSource === 'design') { + const motifDesignPrice = parseFloat(prices.motif_design) || 0; + if (motifDesignPrice > 0) { + setupCosts.push({ label: 'Motiv-Design', amount: motifDesignPrice }); + } + } + + // Textservice + if (state.contentCreateMode === 'textservice') { + const textservicePrice = parseFloat(prices.textservice) || 0; + if (textservicePrice > 0) { + setupCosts.push({ label: 'Textservice', amount: textservicePrice }); + } + } + + // Bei Sammelversand: Versandpauschale zum Gesamtpreis hinzufügen (einmalig, nicht pro Stück) + const bulkShippingFlat = (shippingDetails.type === 'bulk') ? (shippingDetails.flatRate || 0) : 0; + const totalNet = (netPerPiece * quantity) + bulkShippingFlat; + const totalGross = (grossPerPiece * quantity) + (bulkShippingFlat * (1 + taxRate)); + + return { + pricePerPiece: isB2B ? netPerPiece : grossPerPiece, + isGross: !isB2B, + totalPrice: isB2B ? totalNet : totalGross, + details: { + basePrice: basePrice, + multiplier: multiplier, + normalQuantity: normalQty, + formatSurcharge: formatSurcharge, + productPrice: productPrice, + shipping: shippingDetails, + shippingTotal: shippingTotal, + netPerPiece: netPerPiece, + grossPerPiece: grossPerPiece, + taxRate: taxRate * 100, + quantity: quantity, + setupCosts: setupCosts, + }, + }; +} + +// ========== DOM HELPER ========== + +function h(tag, attrs = {}, children = []) { + const el = document.createElement(tag); + for (const [key, val] of Object.entries(attrs)) { + if (key === 'text') { + el.textContent = val; + } else if (key === 'html') { + el.innerHTML = val; + } else if (key.startsWith('on') && typeof val === 'function') { + el.addEventListener(key.slice(2).toLowerCase(), val); + } else if (key === 'class') { + el.className = val; + } else if (key === 'style' && typeof val === 'object') { + Object.assign(el.style, val); + } else if (val !== null && val !== undefined) { + el.setAttribute(key, val); + } + } + for (const child of children) { + if (typeof child === 'string') { + el.appendChild(document.createTextNode(child)); + } else if (child instanceof Node) { + el.appendChild(child); + } + } + return el; +} + +// ========== UI RENDERING ========== + +function renderCard(title, subtitle, bodyContent) { + return h('div', { class: 'sk-card' }, [ + h('div', { class: 'sk-card-head' }, [ + h('h3', { class: 'sk-card-title', text: title }), + subtitle ? h('p', { class: 'sk-card-subtitle', text: subtitle }) : null, + ].filter(Boolean)), + h('div', { class: 'sk-card-body' }, bodyContent), + ]); +} + +function renderProductStep(state, dispatch) { + const blocks = []; + + // Kundentyp Auswahl (identisch zum Konfigurator) + const customerOptions = [ + { + value: 'business', + label: 'Geschäftskunde', + desc: 'Für Unternehmen, Selbstständige und gewerbliche Kunden', + icon: '🏢', + }, + { + value: 'private', + label: 'Privatkunde', + desc: 'Für private Anlässe und persönliche Korrespondenz', + icon: '🏠', + }, + ]; + + const customerBody = customerOptions.map(opt => + h('div', { + class: `sk-option ${state.customerType === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_CUSTOMER_TYPE', customerType: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'customerType', + checked: state.customerType === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: `${opt.icon} ${opt.label}` }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + ]), + ]) + ); + + blocks.push(renderCard( + 'Als was möchten Sie einkaufen?', + 'Wählen Sie Ihren Kundentyp aus', + [h('div', { class: 'sk-options' }, customerBody)] + )); + + // Produktauswahl (nur wenn Kundentyp gewählt) - nutzt getAvailableProductsForCustomerType aus Konfigurator + if (state.customerType) { + const baseProducts = getAvailableProductsForCustomerType(state.customerType); + const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; + const productSettings = settings.products || {}; + const prices = settings.prices || {}; + const taxRate = (prices.tax_rate || 19) / 100; + const isB2B = state.customerType === 'business'; + + // Produkte mit korrekten Labels und Preisen aus Settings anreichern + const products = baseProducts.map(p => { + const pSettings = productSettings[p.key] || {}; + return { + ...p, + label: pSettings.label || p.label, + description: pSettings.description || p.description, + basePrice: parseFloat(pSettings.base_price) || p.basePrice || 2.50, + }; + }); + + const grid = h('div', { class: 'sk-selection-grid' }); + + for (const p of products) { + const displayPrice = isB2B ? p.basePrice : p.basePrice * (1 + taxRate); + const priceText = `ab ${formatEUR(displayPrice)}`; + + const card = h('div', { + class: `sk-selection-card ${state.product?.key === p.key ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_PRODUCT', product: p }), + }, [ + h('div', { class: 'sk-selection-card-image' }, [ + h('div', { text: '📄', style: 'font-size: 48px' }), + ]), + h('div', { class: 'sk-selection-card-content' }, [ + h('div', { class: 'sk-selection-card-title', text: p.label }), + h('div', { class: 'sk-selection-card-price', text: priceText }), + h('div', { class: 'sk-selection-card-desc', text: p.description }), + ]), + ]); + grid.appendChild(card); + } + + blocks.push(renderCard( + 'Welches Produkt benötigen Sie?', + 'Wählen Sie das passende Produkt für Ihren Bedarf', + [grid] + )); + } + + return h('div', { class: 'sk-stack' }, blocks); +} + +function renderCalculatorStep(state, dispatch) { + const blocks = []; + const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; + const prices = settings.prices || {}; + const dynamicPricing = settings.dynamic_pricing || {}; + const isB2B = state.customerType === 'business'; + const taxRate = (prices.tax_rate || 19) / 100; + // Hilfsfunktion: Bei B2C Brutto-Preis anzeigen (inkl. 19% MwSt.) + const displayPrice = (nettoPrice) => isB2B ? nettoPrice : nettoPrice * (1 + taxRate); + // Nutze Helper-Funktionen aus Konfigurator für korrekte Prüfung + const isFollowups = state.product?.isFollowUp === true; + const isPostcardLike = state.product?.supportsMotif === true; + + // Produkt-Info Header + blocks.push(h('div', { class: 'sk-topbar' }, [ + h('div', { class: 'sk-product-info' }, [ + h('div', { class: 'sk-product-icon', text: state.product?.icon || '📄' }), + h('div', {}, [ + h('div', { class: 'sk-product-label', text: state.product?.label }), + h('div', { class: 'sk-text-small sk-text-muted', text: isB2B ? 'Geschäftskunde' : 'Privatkunde' }), + ]), + ]), + h('button', { + class: 'sk-btn sk-btn-secondary', + onclick: () => dispatch({ type: 'GO_BACK' }), + text: '← Produkt ändern', + }), + ])); + + // ========== MENGE / FOLLOW-UP VOLUMEN ========== + if (isFollowups) { + // Follow-ups: Monatsvolumen (identisch zum Konfigurator) + const volumes = [ + { value: '5-49', label: '5-49 Follow-ups pro Monat' }, + { value: '50-199', label: '50-199 Follow-ups pro Monat' }, + { value: '200-499', label: '200-499 Follow-ups pro Monat' }, + { value: '500-999', label: '500-999 Follow-ups pro Monat' }, + { value: '1000+', label: '1000+ Follow-ups pro Monat' }, + ]; + + const volBody = volumes.map(vol => + h('div', { + class: `sk-option ${state.followupYearlyVolume === vol.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_FOLLOWUP_VOLUME', volume: vol.value }), + }, [ + h('input', { + type: 'radio', + name: 'volume', + checked: state.followupYearlyVolume === vol.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: vol.label }), + ]), + ]) + ); + + blocks.push(renderCard( + 'Um wie viele Follow-ups geht es ca. pro Monat?', + 'Dies hilft uns bei der Preiskalkulation', + [h('div', { class: 'sk-options' }, volBody)] + )); + } else { + // Normale Produkte: Menge (identisch zum Konfigurator) + const minQuantity = isB2B + ? (dynamicPricing.business_min_quantity || 50) + : (dynamicPricing.private_min_quantity || 10); + const normalQuantity = isB2B + ? (dynamicPricing.business_normal_quantity || 200) + : (dynamicPricing.private_normal_quantity || 50); + + const quantityInput = h('input', { + class: 'sk-input', + type: 'number', + min: String(minQuantity), + value: String(state.quantity), + onchange: (e) => { + let val = parseInt(e.target.value) || minQuantity; + if (val < minQuantity) val = minQuantity; + dispatch({ type: 'SET_QUANTITY', quantity: val }); + }, + }); + + blocks.push(renderCard( + 'Wie viele Schriftstücke benötigen Sie?', + `Mindestmenge: ${minQuantity} Stück • Unser bester Preis gilt ab ${normalQuantity} Stück`, + [quantityInput] + )); + } + + // ========== FORMAT (nur A4 vs A6 fragen - Hoch/Querformat macht keinen Preisunterschied) ========== + // Prüfen ob sowohl A4 als auch A6 verfügbar sind + const hasA4 = state.product?.formats?.includes('a4'); + const hasA6 = state.product?.formats?.some(f => f === 'a6p' || f === 'a6l'); + + if (hasA4 && hasA6) { + const a4Surcharge = prices.a4_upgrade_surcharge || 0; + const hasA4Surcharge = isPostcardLike; + + const formatOptions = [ + { value: 'a6p', label: 'A6', desc: 'Kompaktes Format, ideal für Postkarten und kurze Nachrichten', price: '' }, + { value: 'a4', label: 'A4', desc: 'Standardformat für ausführliche Briefe', price: hasA4Surcharge ? `+ ${formatEUR(displayPrice(a4Surcharge))}` : '' }, + ]; + + const formatBody = formatOptions.map(opt => { + // Für A6: prüfen ob a6p oder a6l aktuell gewählt ist + const isSelected = opt.value === 'a4' ? state.format === 'a4' : (state.format === 'a6p' || state.format === 'a6l'); + return h('div', { + class: `sk-option ${isSelected ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_FORMAT', format: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'format', + checked: isSelected ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]); + }); + + blocks.push(renderCard( + 'Welches Format soll das Schriftstück haben?', + null, + [h('div', { class: 'sk-options' }, formatBody)] + )); + } + + // ========== FOLLOW-UP ERSTELLUNGSMODUS ========== + if (isFollowups) { + const apiPrice = prices.api_connection || 250; + + const createModes = [ + { + value: 'auto', + label: 'Automatisch aus System', + desc: 'Wir verbinden uns mit Ihrem CRM/Shop-System', + price: `Einmalig ${formatEUR(displayPrice(apiPrice))}`, + }, + { + value: 'manual', + label: 'Manuell', + desc: 'Sie senden uns die Empfängerliste im gewählten Rhythmus', + price: '', + }, + ]; + + const createBody = createModes.map(mode => + h('div', { + class: `sk-option ${state.followupCreateMode === mode.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_FOLLOWUP_CREATE_MODE', mode: mode.value }), + }, [ + h('input', { + type: 'radio', + name: 'createMode', + checked: state.followupCreateMode === mode.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: mode.label }), + h('div', { class: 'sk-option-desc', text: mode.desc }), + mode.price ? h('div', { class: 'sk-option-price', text: mode.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Wie sollen die Follow-ups erstellt werden?', + null, + [h('div', { class: 'sk-options' }, createBody)] + )); + } + + // ========== VERSANDART (nicht bei Follow-ups) ========== + if (!isFollowups) { + const shippingBulk = prices.shipping_bulk || 4.95; + const shippingDomestic = prices.shipping_domestic || 0.95; + const shippingService = prices.shipping_service || 0.95; + const envelopeBase = prices.envelope_base || 0.50; + const envelopeLabeling = prices.envelope_labeling || 0.50; + + const domesticPrice = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; + const internationalPrice = (prices.shipping_international || 1.25) + shippingService + envelopeBase + envelopeLabeling; + + const shippingOptions = [ + { + value: 'direct', + label: 'Einzeln an die Empfänger', + desc: 'Wir versenden direkt an Ihre Empfänger – inklusive Kuvertierung und Beschriftung', + price: `Inland: ${formatEUR(displayPrice(domesticPrice))} / Ausland: ${formatEUR(displayPrice(internationalPrice))} pro Stück`, + }, + { + value: 'bulk', + label: 'Sammelversand an Sie', + desc: 'Sie erhalten alle Schriftstücke zur eigenen Verteilung', + price: `Einmalig ${formatEUR(displayPrice(shippingBulk))} Versandkosten`, + }, + ]; + + const shippingBody = shippingOptions.map(opt => + h('div', { + class: `sk-option ${state.shippingMode === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_SHIPPING_MODE', shippingMode: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'shippingMode', + checked: state.shippingMode === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + h('div', { class: 'sk-option-price', text: opt.price }), + ]), + ]) + ); + + blocks.push(renderCard( + 'Wie sollen die Schriftstücke versendet werden?', + 'Wählen Sie die passende Versandart', + [h('div', { class: 'sk-options' }, shippingBody)] + )); + + // ========== KUVERT BEI SAMMELVERSAND ========== + if (state.shippingMode === 'bulk') { + const envOptions = [ + { value: true, label: 'Ja, ich wünsche ein Kuvert', price: `+ ${formatEUR(displayPrice(envelopeBase))}` }, + { value: false, label: 'Nein, ich benötige kein Kuvert', price: '' }, + ]; + + const envBody = envOptions.map(opt => + h('div', { + class: `sk-option ${state.envelope === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_ENVELOPE', envelope: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'envelope', + checked: state.envelope === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Benötigen Sie ein Kuvert?', + null, + [h('div', { class: 'sk-options' }, envBody)] + )); + + // Beschriftungsart nur wenn Kuvert gewählt + if (state.envelope) { + const modeOptions = [ + { + value: 'recipientData', + label: 'Empfängerdaten', + desc: 'Klassische Adressierung mit Name und Anschrift', + price: `+ ${formatEUR(displayPrice(envelopeLabeling))}`, + }, + { + value: 'customText', + label: 'Individueller Text', + desc: 'Freier Text mit Platzhaltern', + price: `+ ${formatEUR(displayPrice(envelopeLabeling))}`, + }, + { + value: 'none', + label: 'Keine Beschriftung', + desc: 'Umschlag bleibt unbeschriftet', + price: '', + }, + ]; + + const modeBody = modeOptions.map(opt => + h('div', { + class: `sk-option ${state.envelopeMode === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_ENVELOPE_MODE', mode: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'envelopeMode', + checked: state.envelopeMode === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Wie soll das Kuvert beschriftet werden?', + null, + [h('div', { class: 'sk-options' }, modeBody)] + )); + } + } + } + + // Follow-ups: Kein Umschlag-Optionen Block - immer Direktversand mit Kuvert + + // ========== MOTIV (nur bei Postkarten/Einladungen mit A6) ========== + if (isPostcardLike && state.format !== 'a4') { + const motifOptions = [ + { value: true, label: 'Ja, ich möchte ein Motiv' }, + { value: false, label: 'Nein, kein Motiv' }, + ]; + + const motifBody = motifOptions.map(opt => + h('div', { + class: `sk-option ${state.motifNeed === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_MOTIF_NEED', motifNeed: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'motif', + checked: state.motifNeed === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + ]), + ]) + ); + + blocks.push(renderCard( + 'Soll ein Motiv auf der Vorderseite abgebildet werden?', + null, + [h('div', { class: 'sk-options' }, motifBody)] + )); + + // Motiv-Quelle + if (state.motifNeed) { + const motifUploadPrice = prices.motif_upload || 0.30; + const motifDesignPrice = prices.motif_design || 0; + + const sourceOptions = [ + { + value: 'upload', + label: 'Eigenes Motiv hochladen', + desc: 'Sie laden Ihr eigenes Bild hoch', + price: motifUploadPrice > 0 ? `Einmalig ${formatEUR(displayPrice(motifUploadPrice))}` : '', + }, + { + value: 'design', + label: 'Motiv erstellen lassen', + desc: 'Wir gestalten ein Motiv nach Ihren Wünschen', + price: motifDesignPrice > 0 ? `Einmalig ${formatEUR(displayPrice(motifDesignPrice))}` : '', + }, + ]; + + const sourceBody = sourceOptions.map(opt => + h('div', { + class: `sk-option ${state.motifSource === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_MOTIF_SOURCE', source: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'motifSource', + checked: state.motifSource === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Woher kommt das Motiv?', + null, + [h('div', { class: 'sk-options' }, sourceBody)] + )); + } + } + + // ========== TEXTERSTELLUNG (für alle Produkte inkl. Follow-ups) ========== + { + const textservicePrice = prices.textservice || 0; + + const contentOptions = [ + { + value: 'self', + label: 'Ich erstelle den Text selbst', + desc: 'Sie geben den vollständigen Text vor', + price: '', + }, + { + value: 'textservice', + label: 'Textservice beauftragen', + desc: 'Wir erstellen den Text professionell für Sie', + price: textservicePrice > 0 ? `Einmalig ${formatEUR(displayPrice(textservicePrice))}` : '', + }, + ]; + + const contentBody = contentOptions.map(opt => + h('div', { + class: `sk-option ${state.contentCreateMode === opt.value ? 'is-selected' : ''}`, + onclick: () => dispatch({ type: 'SET_CONTENT_CREATE_MODE', mode: opt.value }), + }, [ + h('input', { + type: 'radio', + name: 'contentMode', + checked: state.contentCreateMode === opt.value ? 'checked' : null, + }), + h('div', { class: 'sk-option-content' }, [ + h('div', { class: 'sk-option-label', text: opt.label }), + h('div', { class: 'sk-option-desc', text: opt.desc }), + opt.price ? h('div', { class: 'sk-option-price', text: opt.price }) : null, + ].filter(Boolean)), + ]) + ); + + blocks.push(renderCard( + 'Möchten Sie den Inhalt selbst erstellen?', + null, + [h('div', { class: 'sk-options' }, contentBody)] + )); + } + + return h('div', { class: 'sk-stack' }, blocks); +} + +function renderPriceSidebar(state) { + const priceData = calculatePrice(state); + + if (!priceData) { + return h('div', { class: 'sk-preview-card' }, [ + h('div', { class: 'sk-preview-title', text: 'Preisberechnung' }), + h('div', { class: 'sk-preview-sub', text: 'Wählen Sie ein Produkt aus' }), + ]); + } + + const isFollowups = state.product?.isFollowUp === true; + const taxRate = Math.round(priceData.details.taxRate) || 19; + const taxMultiplier = priceData.isGross ? (1 + taxRate / 100) : 1; // Brutto-Multiplikator für B2C + const taxNote = priceData.isGross ? `inkl. ${taxRate}% MwSt.` : `zzgl. ${taxRate}% MwSt.`; + + // Preiszeilen sammeln - bei B2C (isGross) Brutto-Werte anzeigen + const priceRows = []; + + // Produktpreis (dynamisch) - OHNE Formataufschlag, da dieser separat angezeigt wird + const baseProductPrice = priceData.details.productPrice - (priceData.details.formatSurcharge || 0); + priceRows.push({ label: 'Schriftstück', value: baseProductPrice * taxMultiplier }); + + // Format-Aufschlag (falls vorhanden) + if (priceData.details.formatSurcharge > 0) { + priceRows.push({ label: 'A4-Aufschlag', value: priceData.details.formatSurcharge * taxMultiplier }); + } + + // Direktversand: Zusammengefasst als ein Posten + if (priceData.details.shipping?.type === 'direct') { + const totalDirectShipping = priceData.details.shipping.porto + + priceData.details.shipping.service + + priceData.details.shipping.envelope + + (priceData.details.shipping.labeling || 0); + priceRows.push({ label: 'Einzeln an Empfänger', value: totalDirectShipping * taxMultiplier }); + } else if (priceData.details.shipping?.type === 'bulk') { + // Sammelversand: Kuvert + Beschriftung (falls gewählt) + if (priceData.details.shipping.envelopeCost > 0) { + priceRows.push({ label: 'Kuvert', value: priceData.details.shipping.envelopeCost * taxMultiplier }); + } + if (priceData.details.shipping.labelingCost > 0) { + // Label je nach Modus + const labelText = priceData.details.shipping.envelopeMode === 'customText' + ? 'Individueller Text' + : 'Empfängerdaten'; + priceRows.push({ label: labelText, value: priceData.details.shipping.labelingCost * taxMultiplier }); + } + // KEINE anteilige Versandpauschale pro Stück - wird separat als Pauschale angezeigt + } + + // Follow-ups: Versand zusammengefasst + if (isFollowups) { + const totalDirectShipping = priceData.details.shippingDomestic + + priceData.details.shippingService + + priceData.details.envelopeBase + + (priceData.details.envelopeLabeling || 0); + priceRows.push({ label: 'Einzeln an Empfänger', value: totalDirectShipping * taxMultiplier }); + } + + // Preistabelle rendern + const priceTable = h('table', { class: 'sk-calc-price-table' }, [ + h('tbody', {}, priceRows.map(row => + h('tr', {}, [ + h('td', { text: row.label }), + h('td', { text: formatEUR(row.value) }), + ]) + )), + ]); + + // Einmalige Kosten + let setupSection = null; + if (priceData.details.setupCosts?.length > 0) { + const setupTable = h('table', { class: 'sk-calc-price-table' }, [ + h('tbody', {}, priceData.details.setupCosts.map(cost => + h('tr', {}, [ + h('td', { text: cost.label }), + h('td', { text: formatEUR(cost.amount * taxMultiplier) }), + ]) + )), + ]); + + setupSection = h('div', { class: 'sk-calc-setup-section' }, [ + h('div', { class: 'sk-calc-section-label', text: 'Einmalige Kosten' }), + setupTable, + ]); + } + + // Gesamtpreis (nur bei Standardprodukten) - netto für B2B, brutto für B2C + let totalSection = null; + if (!isFollowups && priceData.totalPrice) { + const totalLabel = priceData.isGross + ? `Gesamt inkl. MwSt. (${state.quantity} St.)` + : `Gesamt netto (${state.quantity} St.)`; + totalSection = h('div', { class: 'sk-calc-total-section' }, [ + h('div', { class: 'sk-calc-total-row' }, [ + h('span', { text: totalLabel }), + h('span', { class: 'sk-calc-total-value', text: formatEUR(priceData.totalPrice) }), + ]), + ]); + } + + // Sammelversand Hinweis + let bulkNote = null; + if (priceData.details.shipping?.type === 'bulk') { + bulkNote = h('div', { class: 'sk-calc-note' }, [ + h('span', { text: `zzgl. ${formatEUR(priceData.details.shipping.flatRate)} Versandpauschale` }), + ]); + } + + // Mengenhinweis + let quantityNote = null; + if (!isFollowups && priceData.details.multiplier > 1.01) { + quantityNote = h('div', { class: 'sk-calc-note sk-calc-note--info' }, [ + h('span', { text: `Tipp: Ab ${priceData.details.normalQuantity} Stück erhalten Sie unseren besten Preis.` }), + ]); + } + + // Staffelpreis-Hinweis bei Follow-ups + let tierNote = null; + if (isFollowups) { + tierNote = h('div', { class: 'sk-calc-note' }, [ + h('span', { text: `Staffelpreis für ${priceData.details.volumeTier} Follow-ups/Monat` }), + ]); + } + + return h('div', { class: 'sk-calc-sidebar-card' }, [ + // Hauptpreis + h('div', { class: 'sk-calc-main-price' }, [ + h('div', { class: 'sk-calc-main-price__value', text: formatEUR(priceData.pricePerPiece) }), + h('div', { class: 'sk-calc-main-price__label', text: 'pro Stück' }), + h('div', { class: 'sk-calc-main-price__note', text: taxNote }), + ]), + + // Preisaufschlüsselung + h('div', { class: 'sk-calc-details' }, [ + priceTable, + bulkNote, + quantityNote, + tierNote, + ].filter(Boolean)), + + // Einmalige Kosten + setupSection, + + // Gesamtpreis + totalSection, + + // CTA Button + h('a', { + class: 'sk-calc-cta-btn', + href: `/konfigurator/?${state.product.key}`, + }, [ + h('span', { text: 'Jetzt konfigurieren' }), + h('span', { class: 'sk-calc-cta-arrow', text: '→' }), + ]), + ].filter(Boolean)); +} + +// ========== MAIN RENDER ========== + +function render(state, dispatch) { + const container = document.querySelector('[data-skrift-preisrechner]'); + if (!container) return; + + // Bei Produktauswahl: Volle Breite (1200px wie Calculator) + if (state.step === STEPS.PRODUCT) { + const wrapper = h('div', {}); + wrapper.appendChild(renderProductStep(state, dispatch)); + container.innerHTML = ''; + container.appendChild(wrapper); + return; + } + + // Calculator-Step: 2-Spalten-Layout mit Sidebar + const layout = h('div', { class: 'sk-configurator__layout' }); + const main = h('div', { class: 'sk-main' }); + const side = h('aside', { class: 'sk-side' }); + + // Hauptinhalt + const calculatorContent = renderCalculatorStep(state, dispatch); + main.appendChild(calculatorContent); + + // Mobile Sidebar (wird am Ende des Hauptinhalts angezeigt) + const mobileSidebar = h('div', { class: 'sk-calc-mobile-sidebar' }); + mobileSidebar.appendChild(renderPriceSidebar(state)); + main.appendChild(mobileSidebar); + + // Desktop Sidebar + side.appendChild(renderPriceSidebar(state)); + + layout.appendChild(main); + layout.appendChild(side); + + container.innerHTML = ''; + container.appendChild(layout); +} + +// ========== INIT ========== + +export function initPriceCalculator() { + const container = document.querySelector('[data-skrift-preisrechner]'); + if (!container) return; + + let state = createInitialState(); + + function dispatch(action) { + state = reducer(state, action); + render(state, dispatch); + } + + // Initial render + render(state, dispatch); +} + +// Auto-Init wenn DOM ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initPriceCalculator); +} else { + initPriceCalculator(); +} diff --git a/skrift-configurator/check-db.php b/skrift-configurator/check-db.php new file mode 100644 index 0000000..6ba4ce7 --- /dev/null +++ b/skrift-configurator/check-db.php @@ -0,0 +1,110 @@ + + + + + + Gutschein DB Check + + + +

    🔍 Gutschein Datenbank Check

    + + + +
    +

    1. Datenbank Status

    +

    Anzahl Gutscheine:

    + +

    ⚠️ KEINE GUTSCHEINE IN DER DATENBANK!

    + +

    ✅ Gutscheine gefunden!

    + +
    + +
    +

    2. Rohe Daten (PHP)

    +
    +
    + +
    +

    3. JSON Encoding (wie im Frontend)

    +
    +
    + +
    +

    4. JavaScript Test

    + +

    Öffnen Sie die Browser Console (F12) für JavaScript-Output

    +
    + + +
    +

    ⚠️ Lösung: Gutscheine erstellen

    +

    Es sind keine Gutscheine in der Datenbank. Bitte:

    +
      +
    1. Gehen Sie zu: Gutschein-Verwaltung
    2. +
    3. Erstellen Sie einen Test-Gutschein (z.B. Code: TEST10, Typ: Prozent, Wert: 10)
    4. +
    5. Oder führen Sie create-test-voucher.php aus
    6. +
    +
    + + +
    +

    5. Schnell-Fix: Test-Gutschein erstellen

    +
    + +
    + + 'TEST10', + 'type' => 'percent', + 'value' => 10, + 'expiry_date' => '', + 'usage_limit' => 0, + 'usage_count' => 0, + ]; + update_option('skrift_konfigurator_vouchers', $vouchers); + echo '

    ✅ Gutschein TEST10 wurde erstellt! Seite neu laden...

    '; + echo ''; + } + ?> +
    + + + diff --git a/skrift-configurator/create-test-voucher.php b/skrift-configurator/create-test-voucher.php new file mode 100644 index 0000000..adfcb45 --- /dev/null +++ b/skrift-configurator/create-test-voucher.php @@ -0,0 +1,61 @@ + 'TEST10', + 'type' => 'percent', + 'value' => 10, + 'expiry_date' => '', // Unbegrenzt + 'usage_limit' => 0, // Unbegrenzt + 'usage_count' => 0, +]; + +// Test-Gutschein 2: 5€ Rabatt +$vouchers['SAVE5'] = [ + 'code' => 'SAVE5', + 'type' => 'fixed', + 'value' => 5.00, + 'expiry_date' => '', // Unbegrenzt + 'usage_limit' => 0, // Unbegrenzt + 'usage_count' => 0, +]; + +// Test-Gutschein 3: 20% Rabatt mit Limit +$vouchers['WELCOME20'] = [ + 'code' => 'WELCOME20', + 'type' => 'percent', + 'value' => 20, + 'expiry_date' => date('Y-m-d', strtotime('+30 days')), // 30 Tage gültig + 'usage_limit' => 10, // Max 10x einlösbar + 'usage_count' => 0, +]; + +update_option('skrift_konfigurator_vouchers', $vouchers); + +echo '

    ✓ Test-Gutscheine erfolgreich erstellt!

    '; +echo '
      '; +echo '
    • TEST10 - 10% Rabatt (unbegrenzt)
    • '; +echo '
    • SAVE5 - 5,00€ Rabatt (unbegrenzt)
    • '; +echo '
    • WELCOME20 - 20% Rabatt (30 Tage gültig, max. 10x)
    • '; +echo '
    '; +echo '

    → Gutscheine im Backend anzeigen

    '; +echo '

    WICHTIG: Bitte löschen Sie diese Datei jetzt aus Sicherheitsgründen!

    '; diff --git a/skrift-configurator/debug-vouchers.php b/skrift-configurator/debug-vouchers.php new file mode 100644 index 0000000..b445914 --- /dev/null +++ b/skrift-configurator/debug-vouchers.php @@ -0,0 +1,72 @@ +Gutschein Debug'; +echo ''; + +// 1. Direkt aus Datenbank lesen +$vouchers_db = get_option('skrift_konfigurator_vouchers', []); +echo '

    1. Direkt aus Datenbank (get_option)

    '; +echo '
    ';
    +print_r($vouchers_db);
    +echo '
    '; + +// 2. Über die Klasse +require_once plugin_dir_path(__FILE__) . 'includes/admin-vouchers.php'; +$vouchers_class = Skrift_Konfigurator_Vouchers::get_vouchers(); +echo '

    2. Über Klassen-Methode get_vouchers()

    '; +echo '
    ';
    +print_r($vouchers_class);
    +echo '
    '; + +// 3. JSON-Encoding prüfen (wie wp_localize_script es macht) +$vouchers_json = json_encode($vouchers_class); +echo '

    3. JSON-Encoded (wie wp_localize_script)

    '; +echo '
    ';
    +echo htmlspecialchars($vouchers_json);
    +echo '
    '; + +// 4. Zurück decodiert +$vouchers_decoded = json_decode($vouchers_json, true); +echo '

    4. JSON wieder decodiert

    '; +echo '
    ';
    +print_r($vouchers_decoded);
    +echo '
    '; + +// 5. Test: Ist es ein assoziatives Array oder Objekt? +echo '

    5. Datentyp-Analyse

    '; +echo '
    ';
    +echo 'is_array: ' . (is_array($vouchers_class) ? 'JA' : 'NEIN') . "\n";
    +echo 'count: ' . count($vouchers_class) . "\n";
    +echo 'empty: ' . (empty($vouchers_class) ? 'JA' : 'NEIN') . "\n";
    +echo 'Keys: ' . print_r(array_keys($vouchers_class), true) . "\n";
    +echo '
    '; + +// 6. Simuliere wp_localize_script +echo '

    6. Simuliertes wp_localize_script Output

    '; +echo ''; + +echo '

    Öffnen Sie die Browser-Console, um das simulierte Output zu sehen!

    '; diff --git a/skrift-configurator/includes/admin-orders.php b/skrift-configurator/includes/admin-orders.php new file mode 100644 index 0000000..d4655b8 --- /dev/null +++ b/skrift-configurator/includes/admin-orders.php @@ -0,0 +1,186 @@ + 'POST', + 'callback' => [$this, 'rest_generate_order_number'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Bestellung registrieren (nach erfolgreicher Zahlung) + register_rest_route('skrift/v1', '/order/register', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_register_order'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + } + + /** + * Generiert die nächste fortlaufende Bestellnummer + * Format: S-YYYY-MM-DD-XXX (z.B. S-2026-01-12-001) + * Verwendet Transient-Lock um Race Conditions zu vermeiden + */ + public static function generate_order_number() { + $lock_key = 'skrift_order_number_lock'; + $max_attempts = 10; + $attempt = 0; + + // Versuche Lock zu bekommen (einfaches Locking mit Transients) + while ($attempt < $max_attempts) { + // Prüfe ob Lock existiert + if (get_transient($lock_key) === false) { + // Setze Lock (5 Sekunden Timeout) + set_transient($lock_key, time(), 5); + + // Aktuellen Counter holen + $counter_data = get_option(self::COUNTER_OPTION_KEY, [ + 'date' => '', + 'counter' => 0 + ]); + + $today = date('Y-m-d'); + + // Counter zurücksetzen wenn neuer Tag + if ($counter_data['date'] !== $today) { + $counter_data = [ + 'date' => $today, + 'counter' => 0 + ]; + } + + // Counter erhöhen + $counter_data['counter']++; + + // Speichern + update_option(self::COUNTER_OPTION_KEY, $counter_data); + + // Lock freigeben + delete_transient($lock_key); + + // Format: S-YYYY-MM-DD-XXX + $year = date('Y'); + $month = date('m'); + $day = date('d'); + $number = str_pad($counter_data['counter'], 3, '0', STR_PAD_LEFT); + + return "S-{$year}-{$month}-{$day}-{$number}"; + } + + // Warte kurz und versuche erneut + usleep(100000); // 100ms + $attempt++; + } + + // Fallback wenn Lock nicht bekommen werden konnte + // Verwende Microtime für Eindeutigkeit + $year = date('Y'); + $month = date('m'); + $day = date('d'); + $micro = substr(str_replace('.', '', microtime(true)), -6); + + return "S-{$year}-{$month}-{$day}-{$micro}"; + } + + /** + * REST API Endpoint: Neue Bestellnummer generieren + */ + public function rest_generate_order_number($request) { + $order_number = self::generate_order_number(); + + return [ + 'success' => true, + 'orderNumber' => $order_number + ]; + } + + /** + * REST API Endpoint: Bestellung registrieren + */ + public function rest_register_order($request) { + $order_number = $request->get_param('orderNumber'); + $customer = $request->get_param('customer'); + $quote = $request->get_param('quote'); + $payment_method = $request->get_param('paymentMethod'); + $payment_id = $request->get_param('paymentId'); + + if (empty($order_number)) { + return new WP_Error('missing_order_number', 'Bestellnummer fehlt', ['status' => 400]); + } + + // Bestellung speichern + $orders = get_option(self::ORDERS_OPTION_KEY, []); + + $orders[$order_number] = [ + 'orderNumber' => $order_number, + 'customer' => $customer, + 'quote' => $quote, + 'paymentMethod' => $payment_method, + 'paymentId' => $payment_id, + 'createdAt' => current_time('mysql'), + 'status' => 'pending' + ]; + + update_option(self::ORDERS_OPTION_KEY, $orders); + + return [ + 'success' => true, + 'orderNumber' => $order_number, + 'message' => 'Bestellung erfolgreich registriert' + ]; + } + + /** + * Bestellung als bezahlt markieren + */ + public static function mark_order_paid($order_number, $payment_id = null) { + $orders = get_option(self::ORDERS_OPTION_KEY, []); + + if (isset($orders[$order_number])) { + $orders[$order_number]['status'] = 'paid'; + $orders[$order_number]['paidAt'] = current_time('mysql'); + if ($payment_id) { + $orders[$order_number]['paymentId'] = $payment_id; + } + update_option(self::ORDERS_OPTION_KEY, $orders); + return true; + } + + return false; + } + + /** + * Alle Bestellungen abrufen + */ + public static function get_orders() { + return get_option(self::ORDERS_OPTION_KEY, []); + } + + /** + * Einzelne Bestellung abrufen + */ + public static function get_order($order_number) { + $orders = self::get_orders(); + return $orders[$order_number] ?? null; + } +} + +new Skrift_Konfigurator_Orders(); diff --git a/skrift-configurator/includes/admin-settings.php b/skrift-configurator/includes/admin-settings.php new file mode 100644 index 0000000..71855d5 --- /dev/null +++ b/skrift-configurator/includes/admin-settings.php @@ -0,0 +1,943 @@ + 'array', + 'sanitize_callback' => [$this, 'sanitize_settings'], + ]); + } + + public function sanitize_settings($input) { + $sanitized = []; + + // Produkte sanitieren + if (isset($input['products']) && is_array($input['products'])) { + $sanitized['products'] = []; + foreach ($input['products'] as $key => $product) { + $sanitized['products'][sanitize_key($key)] = [ + 'label' => sanitize_text_field($product['label'] ?? ''), + 'description' => sanitize_textarea_field($product['description'] ?? ''), + 'base_price' => floatval($product['base_price'] ?? 0), + ]; + } + } + + // Preise sanitieren + if (isset($input['prices']) && is_array($input['prices'])) { + $sanitized['prices'] = []; + foreach ($input['prices'] as $key => $value) { + $sanitized['prices'][sanitize_key($key)] = floatval($value); + } + } + + // Dynamische Preisformeln sanitieren + if (isset($input['dynamic_pricing'])) { + $sanitized['dynamic_pricing'] = [ + 'business_formula' => sanitize_textarea_field($input['dynamic_pricing']['business_formula'] ?? ''), + 'private_formula' => sanitize_textarea_field($input['dynamic_pricing']['private_formula'] ?? ''), + 'business_min_quantity' => intval($input['dynamic_pricing']['business_min_quantity'] ?? 0), + 'private_min_quantity' => intval($input['dynamic_pricing']['private_min_quantity'] ?? 0), + 'business_normal_quantity' => intval($input['dynamic_pricing']['business_normal_quantity'] ?? 0), + 'private_normal_quantity' => intval($input['dynamic_pricing']['private_normal_quantity'] ?? 0), + ]; + } + + // Backend-Verbindung sanitieren + if (isset($input['backend_connection'])) { + $sanitized['backend_connection'] = [ + 'api_url' => esc_url_raw($input['backend_connection']['api_url'] ?? ''), + 'api_token' => sanitize_text_field($input['backend_connection']['api_token'] ?? ''), + 'webhook_url_business' => esc_url_raw($input['backend_connection']['webhook_url_business'] ?? ''), + 'webhook_url_private' => esc_url_raw($input['backend_connection']['webhook_url_private'] ?? ''), + 'redirect_url_business' => esc_url_raw($input['backend_connection']['redirect_url_business'] ?? ''), + 'redirect_url_private' => esc_url_raw($input['backend_connection']['redirect_url_private'] ?? ''), + ]; + } + + // REST API Key sanitieren + if (isset($input['api_security'])) { + $sanitized['api_security'] = [ + 'api_key' => sanitize_text_field($input['api_security']['api_key'] ?? ''), + ]; + } + + // PayPal-Verbindung sanitieren + if (isset($input['paypal'])) { + $sanitized['paypal'] = [ + 'enabled' => !empty($input['paypal']['enabled']), + 'mode' => sanitize_text_field($input['paypal']['mode'] ?? 'sandbox'), + 'client_id_sandbox' => sanitize_text_field($input['paypal']['client_id_sandbox'] ?? ''), + 'client_secret_sandbox' => sanitize_text_field($input['paypal']['client_secret_sandbox'] ?? ''), + 'client_id_live' => sanitize_text_field($input['paypal']['client_id_live'] ?? ''), + 'client_secret_live' => sanitize_text_field($input['paypal']['client_secret_live'] ?? ''), + ]; + } + + // Schriftmuster und Platzhalter-Hilfe sanitieren + if (isset($input['font_sample'])) { + $sanitized['font_sample'] = [ + 'url' => esc_url_raw($input['font_sample']['url'] ?? ''), + 'placeholder_help_url' => esc_url_raw($input['font_sample']['placeholder_help_url'] ?? ''), + ]; + } + + return $sanitized; + } + + public function render_settings_page(): void { + if (!current_user_can('manage_options')) { + return; + } + + $settings = $this->get_settings(); + ?> + +
    +

    +
    + + + +
    +

    📦 Produkte

    +

    Verwalten Sie Namen, Beschreibungen und Startpreise für alle Produkte.

    + +
    + render_product_card('businessbriefe', 'Businessbriefe', $settings); ?> + render_product_card('business-postkarten', 'Business Postkarten', $settings); ?> + render_product_card('follow-ups', 'Follow-ups', $settings); ?> + render_product_card('einladungen', 'Einladungen', $settings); ?> + render_product_card('private-briefe', 'Private Briefe', $settings); ?> +
    +
    + + +
    +

    📐 Format Aufpreise

    +

    Aufpreise wenn bei bestimmten Produkten das Format gewechselt wird.

    +
    +
    + + Aufpreis pro Stück wenn bei Postkarten oder Einladungen auf A4 gewechselt wird +
    +
    +
    + + +
    +

    🚚 Versand & Umschlag

    +
    +
    + + Portokosten für Versand innerhalb Deutschlands (0% MwSt.) +
    +
    + + Portokosten für Auslandsversand (0% MwSt.) +
    +
    + + Service-Aufschlag für Direktversand (19% MwSt.) +
    +
    + +
    +
    + + Grundpreis für Kuvert +
    +
    + + Aufschlag für Beschriftung des Umschlags (Empfängeradresse oder individueller Text) +
    +
    +
    + + +
    +

    ✨ Zusatzleistungen

    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + Einmalige Einrichtungsgebühr für die API-Anbindung +
    +
    +
    + + +
    +

    📊 Follow-ups Preis-Multiplikatoren

    +

    Die Gesamtkosten pro Schriftstück werden mit diesen Multiplikatoren je nach Menge multipliziert.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    MengeMultiplikator
    5 - 49 Stück + + × +
    50 - 199 Stück + + × +
    200 - 499 Stück + + × +
    500 - 999 Stück + + × +
    1000+ Stück + + × +
    +
    + + +
    +

    📋 Steuern

    +
    +
    + + Gilt für alle Positionen inkl. Versand +
    +
    +
    + + +
    +

    🧮 Dynamische Preisberechnung

    +

    Konfigurieren Sie die dynamische Preisberechnung basierend auf Mengen. Die Formeln unterstützen Platzhalter wie %qty% (aktuelle Menge), %norm_b% (Normalpreis Menge Business), %mind_b% (Mind. Menge Business), %norm_p% (Normalpreis Menge Privat), %mind_p% (Mind. Menge Privat).

    + +
    + + +
    +

    Business

    + +
    + + + Minimale Menge für Business-Bestellungen (außer Follow-ups) +
    + +
    + + + Ab dieser Menge gilt der Normalpreis (Multiplikator = 1) +
    + +
    + + + + JavaScript-Formel zur Berechnung des Preis-Multiplikators.
    + Platzhalter verwenden: %qty% für aktuelle Menge, %norm_b% für Normalpreis-Menge Business, %mind_b% für Mindestmenge Business.
    + Beispiel: (%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))
    + Dies bedeutet: Wenn die Menge >= Normalpreis-Menge ist, dann Multiplikator = 1, sonst dynamische Berechnung. +
    +
    +
    + + +
    +

    Privat

    + +
    + + + Minimale Menge für Private Bestellungen +
    + +
    + + + Ab dieser Menge gilt der Normalpreis (Multiplikator = 1) +
    + +
    + + + + JavaScript-Formel zur Berechnung des Preis-Multiplikators.
    + Platzhalter verwenden: %qty% für aktuelle Menge, %norm_p% für Normalpreis-Menge Privat, %mind_p% für Mindestmenge Privat.
    + Beispiel: (%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))
    + Dies bedeutet: Wenn die Menge >= Normalpreis-Menge ist, dann Multiplikator = 1, sonst dynamische Berechnung. +
    +
    +
    + +
    + +
    + Verfügbare Platzhalter: +
      +
    • %qty% - Aktuelle Menge (eingegebene Stückzahl)
    • +
    • %norm_b% - Normalpreis Menge Business
    • +
    • %mind_b% - Mindestmenge Business
    • +
    • %norm_p% - Normalpreis Menge Privat
    • +
    • %mind_p% - Mindestmenge Privat
    • +
    +
    +
    + + +
    +

    🔌 Backend-Verbindung

    +

    Konfigurieren Sie die Verbindung zum Backend-System für erweiterte Funktionen.

    + +
    +
    + + + Basis-URL des Backend-Systems (z.B. https://api.example.com) +
    + +
    + + + Authentifizierungs-Token für API-Zugriff +
    + +
    + + + Webhook wird nach Klick auf "Jetzt kostenpflichtig bestellen" für Geschäftskunden aufgerufen +
    + +
    + + + Webhook wird nach erfolgreicher PayPal-Zahlung für Privatkunden aufgerufen +
    + +
    + + + Weiterleitung nach Bestellung für Geschäftskunden (nach Klick auf "Jetzt kostenpflichtig bestellen") +
    + +
    + + + Weiterleitung nach erfolgreicher PayPal-Zahlung für Privatkunden +
    +
    +
    + + +
    +

    🔐 REST API Sicherheit

    +

    Konfigurieren Sie einen API-Key für die REST-API-Endpunkte. Dieser Key muss im Header X-Skrift-API-Key mitgesendet werden.

    + +
    +
    + +
    + + +
    + Leer lassen um API-Key-Prüfung zu deaktivieren (nicht empfohlen für Produktion) +
    +
    +
    + + +
    +

    ✍️ Schriftmuster (Vorschau-Fallback)

    +

    Wenn die Vorschau-Generierung fehlschlägt oder nicht verfügbar ist, wird ein "Schriftmuster ansehen"-Link angezeigt. Der Link öffnet sich in einem neuen Tab.

    + +
    +
    + + + URL zur Schriftmuster-Seite (wird in neuem Tab geöffnet) +
    + +
    + + + URL zur Platzhalter-Hilfeseite (wird bei Platzhalter-Infotexten als Link angezeigt) +
    +
    +
    + + +
    +

    💳 PayPal-Verbindung (nur Privatkunden)

    +

    Konfigurieren Sie die PayPal-Zahlungsintegration. PayPal ist nur für Privatkunden aktiviert.

    + +
    +
    + + Aktiviert PayPal als Zahlungsoption für Privatkunden +
    + +
    + + + Sandbox für Tests, Live für echte Zahlungen +
    +
    + +

    Sandbox-Zugangsdaten (Test)

    +
    +
    + + +
    + +
    + + +
    +
    + +

    Live-Zugangsdaten (Produktion)

    +
    +
    + + +
    + +
    + + +
    +
    + +
    + Hinweis: Um PayPal zu aktivieren, benötigen Sie ein PayPal Business-Konto. + Sie erhalten die API-Zugangsdaten im PayPal Developer Dashboard. +
    +
    + + +
    + + +
    +

    URL-Parameter

    +

    Der Konfigurator unterstützt folgende URL-Parameter:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ParameterWerteBeschreibung
    ?businessbriefe
    + ?business-postkarten
    + ?follow-ups
    + ?einladungen
    + ?private-briefe
    Produkt direkt vorauswählen. Der Produktauswahlschritt wird übersprungen.
    quantityZahl (z.B. 100)Menge vorausfüllen.
    formata4, a6h, a6qFormat vorauswählen. a6h = A6 Hochformat, a6q = A6 Querformat.
    noPricePreise im Konfigurator ausblenden.
    noLimitsKeine Mindestmengen. Erlaubt Bestellungen ab 1 Stück.
    + +

    Beispiele

    +
      +
    • /konfigurator/?businessbriefe – Direkt zu Business Briefe
    • +
    • /konfigurator/?einladungen&quantity=25&format=a6h – Einladungen mit 25 Stück im A6 Hochformat
    • +
    • /konfigurator/?businessbriefe&noPrice – Business Briefe ohne Preisanzeige
    • +
    • /konfigurator/?private-briefe&noLimits – Private Briefe ohne Mindestmenge
    • +
    • /konfigurator/?business-postkarten&noLimits&noPrice – Postkarten ohne Mindestmenge und ohne Preise
    • +
    +
    +
    + +
    +

    + +
    + + +
    + +
    + + +
    + +
    + +
    + € +
    +
    +
    + [ + 'businessbriefe' => [ + 'label' => 'Businessbriefe', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 2.50, + ], + 'business-postkarten' => [ + 'label' => 'Business Postkarten', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 1.80, + ], + 'follow-ups' => [ + 'label' => 'Follow-ups', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 2.50, + ], + 'einladungen' => [ + 'label' => 'Einladungen', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 1.80, + ], + 'private-briefe' => [ + 'label' => 'Private Briefe', + 'description' => 'Professionelle handgeschriebene Korrespondenz', + 'base_price' => 2.50, + ], + ], + 'prices' => [ + // Versand & Umschlag + 'shipping_domestic' => 0.95, // Porto Inland + 'shipping_international' => 1.25, // Porto Ausland + 'shipping_service' => 0.95, // Serviceaufschlag Versand + 'shipping_bulk' => 4.95, // Bulkversand einmalig + 'envelope_base' => 0.50, // Kuvert Grundpreis + 'envelope_labeling' => 0.50, // Aufschlag Beschriftung + // Legacy fields for backwards compatibility + 'shipping_direct' => 2.40, + 'envelope_recipient_address' => 0.50, + 'envelope_custom_text' => 0.30, + + // Format Aufpreise + 'a4_upgrade_surcharge' => 0.50, + + // Zusatzleistungen + 'motif_upload' => 0.30, + 'motif_printed' => 0.00, + 'motif_design' => 0.00, + 'textservice' => 0.00, + 'api_connection' => 250.00, + + // Follow-ups Multiplikatoren + 'followup_mult_5_49' => 2.0, + 'followup_mult_50_199' => 1.7, + 'followup_mult_200_499' => 1.4, + 'followup_mult_500_999' => 1.2, + 'followup_mult_1000_plus' => 1.0, + + // Steuern + 'tax_rate' => 19, + 'shipping_tax_rate' => 0, + ], + 'dynamic_pricing' => [ + 'business_formula' => "(%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))", + 'private_formula' => "(%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))", + 'business_min_quantity' => 50, + 'private_min_quantity' => 10, + 'business_normal_quantity' => 200, + 'private_normal_quantity' => 50, + ], + 'backend_connection' => [ + 'api_url' => '', + 'api_token' => '', + 'webhook_url_business' => '', + 'webhook_url_private' => '', + 'redirect_url_business' => '', + 'redirect_url_private' => '', + ], + 'paypal' => [ + 'enabled' => false, + 'mode' => 'sandbox', + 'client_id_sandbox' => '', + 'client_secret_sandbox' => '', + 'client_id_live' => '', + 'client_secret_live' => '', + ], + 'api_security' => [ + 'api_key' => '', + ], + 'font_sample' => [ + 'url' => '', + 'placeholder_help_url' => '', + ], + ]; + + $saved = get_option(self::OPTION_KEY, []); + + // Merge nested arrays properly + $merged = $defaults; + foreach (['products', 'prices', 'dynamic_pricing', 'backend_connection', 'paypal', 'api_security', 'font_sample'] as $section) { + if (isset($saved[$section]) && is_array($saved[$section])) { + $merged[$section] = array_merge($defaults[$section], $saved[$section]); + } + } + + return $merged; + } + + /** + * Prüft ob ein API-Key gültig ist + */ + public static function validate_api_key($provided_key): bool { + $settings = self::get_settings(); + $stored_key = $settings['api_security']['api_key'] ?? ''; + + // Wenn kein Key konfiguriert ist, ist alles erlaubt (für Entwicklung) + if (empty($stored_key)) { + return true; + } + + // Key vergleichen (timing-safe) + return hash_equals($stored_key, $provided_key); + } + + /** + * Permission Callback für REST API mit API-Key + */ + public static function rest_api_key_permission($request): bool { + $api_key = $request->get_header('X-Skrift-API-Key'); + return self::validate_api_key($api_key ?? ''); + } +} + +new Skrift_Konfigurator_Admin_Settings(); diff --git a/skrift-configurator/includes/admin-vouchers.php b/skrift-configurator/includes/admin-vouchers.php new file mode 100644 index 0000000..5702f44 --- /dev/null +++ b/skrift-configurator/includes/admin-vouchers.php @@ -0,0 +1,403 @@ + 'array', + 'sanitize_callback' => [$this, 'sanitize_vouchers'], + ]); + } + + public function sanitize_vouchers($input) { + if (!is_array($input)) { + return []; + } + + $sanitized = []; + foreach ($input as $code => $voucher) { + // Verwende den originalen Code (in Großbuchstaben) als Key + $voucherCode = strtoupper(sanitize_text_field($voucher['code'] ?? '')); + $sanitized[$voucherCode] = [ + 'code' => $voucherCode, + 'type' => in_array($voucher['type'] ?? '', ['percent', 'fixed']) ? $voucher['type'] : 'percent', + 'value' => floatval($voucher['value'] ?? 0), + 'expiry_date' => sanitize_text_field($voucher['expiry_date'] ?? ''), + 'usage_limit' => intval($voucher['usage_limit'] ?? 0), + 'usage_count' => intval($voucher['usage_count'] ?? 0), + ]; + } + + return $sanitized; + } + + public function handle_add_voucher(): void { + if (!current_user_can('manage_options')) { + wp_die('Keine Berechtigung'); + } + + check_admin_referer('sk_add_voucher'); + + $vouchers = get_option(self::OPTION_KEY, []); + $code = strtoupper(sanitize_text_field($_POST['voucher_code'] ?? '')); + + if (empty($code)) { + wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'error' => 'empty_code'], admin_url('options-general.php'))); + exit; + } + + if (isset($vouchers[$code])) { + wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'error' => 'duplicate'], admin_url('options-general.php'))); + exit; + } + + $vouchers[$code] = [ + 'code' => $code, + 'type' => sanitize_text_field($_POST['voucher_type'] ?? 'percent'), + 'value' => floatval($_POST['voucher_value'] ?? 0), + 'expiry_date' => sanitize_text_field($_POST['voucher_expiry'] ?? ''), + 'usage_limit' => intval($_POST['voucher_limit'] ?? 0), + 'usage_count' => 0, + ]; + + update_option(self::OPTION_KEY, $vouchers); + + wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'success' => 'added'], admin_url('options-general.php'))); + exit; + } + + public function handle_delete_voucher(): void { + if (!current_user_can('manage_options')) { + wp_die('Keine Berechtigung'); + } + + check_admin_referer('sk_delete_voucher'); + + $code = sanitize_text_field($_GET['code'] ?? ''); + $vouchers = get_option(self::OPTION_KEY, []); + + if (isset($vouchers[$code])) { + unset($vouchers[$code]); + update_option(self::OPTION_KEY, $vouchers); + } + + wp_redirect(add_query_arg(['page' => 'skrift-vouchers', 'success' => 'deleted'], admin_url('options-general.php'))); + exit; + } + + public function render_vouchers_page(): void { + if (!current_user_can('manage_options')) { + return; + } + + $vouchers = get_option(self::OPTION_KEY, []); + ?> +
    +

    Gutschein-Verwaltung

    + + +
    +

    + +

    +
    + + + +
    +

    + +

    +
    + + +
    +

    Neuen Gutschein erstellen

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +

    Code wird automatisch in Großbuchstaben umgewandelt

    +
    + +
    + +

    Bei Prozent: z.B. 10 für 10% | Bei Festbetrag: z.B. 5.00 für 5€

    +
    + +

    Leer lassen für unbegrenzte Gültigkeit

    +
    + +

    0 = Unbegrenzt oft einlösbar

    +
    + + +
    +
    + +
    +

    Vorhandene Gutscheine

    + + +

    Noch keine Gutscheine vorhanden.

    + + + + + + + + + + + + + + + + $voucher): + $is_expired = !empty($voucher['expiry_date']) && strtotime($voucher['expiry_date']) < time(); + $is_used_up = $voucher['usage_limit'] > 0 && $voucher['usage_count'] >= $voucher['usage_limit']; + $is_active = !$is_expired && !$is_used_up; + ?> + + + + + + + + + + + + +
    CodeTypWertAblaufdatumLimitEingelöstStatusAktionen
    + + + + 0 ? $voucher['usage_limit'] : 'Unbegrenzt'; ?> + + ✓ Aktiv + + ✗ Abgelaufen + + ✗ Limit erreicht + + + + Löschen + +
    + +
    +
    + false, 'error' => 'Gutschein nicht gefunden']; + } + + $voucher = $vouchers[$code]; + + // Ablaufdatum prüfen + if (!empty($voucher['expiry_date']) && strtotime($voucher['expiry_date']) < time()) { + return ['valid' => false, 'error' => 'Gutschein ist abgelaufen']; + } + + // Nutzungslimit prüfen + if ($voucher['usage_limit'] > 0 && $voucher['usage_count'] >= $voucher['usage_limit']) { + return ['valid' => false, 'error' => 'Gutschein wurde bereits zu oft eingelöst']; + } + + return [ + 'valid' => true, + 'voucher' => $voucher + ]; + } + + /** + * Markiert einen Gutschein als verwendet + */ + public static function use_voucher($code) { + $vouchers = self::get_vouchers(); + $code = strtoupper(trim($code)); + + if (isset($vouchers[$code])) { + $vouchers[$code]['usage_count']++; + update_option(self::OPTION_KEY, $vouchers); + return true; + } + + return false; + } + + /** + * REST API Routen registrieren + */ + public function register_rest_routes() { + register_rest_route('skrift/v1', '/voucher/use', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_use_voucher'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + register_rest_route('skrift/v1', '/voucher/validate', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_validate_voucher'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + } + + /** + * REST API Endpoint: Gutschein validieren + */ + public function rest_validate_voucher($request) { + $code = $request->get_param('code'); + + if (empty($code)) { + return new WP_Error('missing_code', 'Gutschein-Code fehlt', ['status' => 400]); + } + + $result = self::validate_voucher($code); + + if ($result['valid']) { + return [ + 'valid' => true, + 'voucher' => [ + 'code' => $result['voucher']['code'], + 'type' => $result['voucher']['type'], + 'value' => $result['voucher']['value'], + ] + ]; + } else { + return [ + 'valid' => false, + 'error' => $result['error'] + ]; + } + } + + /** + * REST API Endpoint: Gutschein als verwendet markieren + */ + public function rest_use_voucher($request) { + $code = $request->get_param('code'); + + if (empty($code)) { + return new WP_Error('missing_code', 'Gutschein-Code fehlt', ['status' => 400]); + } + + $result = self::use_voucher($code); + + if ($result) { + return ['success' => true, 'message' => 'Gutschein wurde als verwendet markiert']; + } else { + return new WP_Error('invalid_code', 'Ungültiger Gutschein-Code', ['status' => 404]); + } + } +} + +new Skrift_Konfigurator_Vouchers(); diff --git a/skrift-configurator/includes/api-proxy.php b/skrift-configurator/includes/api-proxy.php new file mode 100644 index 0000000..11115fd --- /dev/null +++ b/skrift-configurator/includes/api-proxy.php @@ -0,0 +1,351 @@ + 'GET', + 'callback' => [$this, 'proxy_health_check'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Preview Batch generieren + register_rest_route('skrift/v1', '/proxy/preview/batch', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_preview_batch'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Einzelne Preview abrufen + register_rest_route('skrift/v1', '/proxy/preview/(?P[a-zA-Z0-9_-]+)/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'proxy_preview_get'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Order generieren + register_rest_route('skrift/v1', '/proxy/order/generate', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_order_generate'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Order finalisieren + register_rest_route('skrift/v1', '/proxy/order/finalize', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_order_finalize'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + + // Motiv hochladen (ans Docker-Backend weiterleiten) + register_rest_route('skrift/v1', '/proxy/motif/upload', [ + 'methods' => 'POST', + 'callback' => [$this, 'proxy_motif_upload'], + 'permission_callback' => ['Skrift_Konfigurator_Admin_Settings', 'rest_api_key_permission'], + ]); + } + + /** + * Holt Backend-Konfiguration + */ + private function get_backend_config(): array { + $settings = Skrift_Konfigurator_Admin_Settings::get_settings(); + return [ + 'api_url' => $settings['backend_connection']['api_url'] ?? '', + 'api_token' => $settings['backend_connection']['api_token'] ?? '', + ]; + } + + /** + * Führt einen HTTP-Request ans Backend aus + */ + private function make_backend_request(string $method, string $endpoint, array $body = null): array { + $config = $this->get_backend_config(); + + if (empty($config['api_url'])) { + return [ + 'success' => false, + 'error' => 'Backend API URL nicht konfiguriert', + 'status' => 500, + ]; + } + + $url = rtrim($config['api_url'], '/') . $endpoint; + + $args = [ + 'method' => $method, + 'timeout' => 60, + 'headers' => [ + 'Content-Type' => 'application/json', + ], + ]; + + // API Token hinzufügen wenn vorhanden + if (!empty($config['api_token'])) { + $args['headers']['X-API-Token'] = $config['api_token']; + } + + // Body hinzufügen bei POST/PUT + if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH'])) { + $args['body'] = wp_json_encode($body); + } + + $response = wp_remote_request($url, $args); + + if (is_wp_error($response)) { + return [ + 'success' => false, + 'error' => $response->get_error_message(), + 'status' => 500, + ]; + } + + $status_code = wp_remote_retrieve_response_code($response); + $response_body = wp_remote_retrieve_body($response); + + // Versuche JSON zu parsen + $data = json_decode($response_body, true); + + if ($status_code >= 200 && $status_code < 300) { + return [ + 'success' => true, + 'data' => $data ?? $response_body, + 'status' => $status_code, + ]; + } + + return [ + 'success' => false, + 'error' => $data['error'] ?? $data['message'] ?? 'Backend-Fehler', + 'status' => $status_code, + 'data' => $data, + ]; + } + + /** + * Health Check Proxy + */ + public function proxy_health_check($request) { + $result = $this->make_backend_request('GET', '/health'); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + /** + * Preview Batch Proxy + */ + public function proxy_preview_batch($request) { + $body = $request->get_json_params(); + + if (empty($body)) { + return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]); + } + + $result = $this->make_backend_request('POST', '/api/preview/batch', $body); + + if (!$result['success']) { + // Rate Limiting Info weitergeben + if ($result['status'] === 429 && isset($result['data']['retryAfter'])) { + return new WP_REST_Response([ + 'error' => $result['error'], + 'retryAfter' => $result['data']['retryAfter'], + ], 429); + } + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + /** + * Einzelne Preview abrufen Proxy + */ + public function proxy_preview_get($request) { + $session_id = $request->get_param('sessionId'); + $index = $request->get_param('index'); + + // Sicherheits-Validierung: Session-ID darf nur alphanumerische Zeichen, Unterstriche und Bindestriche enthalten + if (!preg_match('/^[a-zA-Z0-9_-]+$/', $session_id)) { + return new WP_Error('invalid_session_id', 'Ungültige Session-ID', ['status' => 400]); + } + + // Index muss eine positive Ganzzahl sein + $index = absint($index); + + $result = $this->make_backend_request('GET', "/api/preview/{$session_id}/{$index}"); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + // Bei SVG-Daten: Content-Type setzen + if (is_string($result['data']) && strpos($result['data'], 'header('Content-Type', 'image/svg+xml'); + return $response; + } + + return $result['data']; + } + + /** + * Order generieren Proxy + */ + public function proxy_order_generate($request) { + $body = $request->get_json_params(); + + if (empty($body)) { + return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]); + } + + $result = $this->make_backend_request('POST', '/api/order/generate', $body); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + /** + * Order finalisieren Proxy + */ + public function proxy_order_finalize($request) { + $body = $request->get_json_params(); + + if (empty($body)) { + return new WP_Error('invalid_request', 'Keine Daten empfangen', ['status' => 400]); + } + + $result = $this->make_backend_request('POST', '/api/order/finalize', $body); + + if (!$result['success']) { + return new WP_Error('backend_error', $result['error'], ['status' => $result['status']]); + } + + return $result['data']; + } + + /** + * Motiv hochladen Proxy + * Leitet Datei ans Docker-Backend weiter, speichert dort im Auftragsordner + */ + public function proxy_motif_upload($request) { + // Datei aus Request holen + $files = $request->get_file_params(); + + if (empty($files['motif'])) { + return new WP_Error('no_file', 'Keine Datei empfangen', ['status' => 400]); + } + + $file = $files['motif']; + $order_number = $request->get_param('orderNumber'); + + if (empty($order_number)) { + return new WP_Error('no_order_number', 'Bestellnummer fehlt', ['status' => 400]); + } + + // Überprüfe auf Upload-Fehler + if ($file['error'] !== UPLOAD_ERR_OK) { + return new WP_Error('upload_error', 'Upload-Fehler: ' . $file['error'], ['status' => 400]); + } + + // Erlaubte Dateitypen prüfen + $allowed_types = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml', 'application/pdf']; + $file_type = wp_check_filetype($file['name']); + + // MIME-Type Prüfung + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $real_type = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + // SVG wird oft als text/xml erkannt + if ($real_type === 'text/xml' || $real_type === 'text/plain') { + $content = file_get_contents($file['tmp_name']); + if (strpos($content, ' 400]); + } + + // Datei ans Docker-Backend senden (multipart/form-data) + $config = $this->get_backend_config(); + + if (empty($config['api_url'])) { + return new WP_Error('no_backend', 'Backend API URL nicht konfiguriert', ['status' => 500]); + } + + $url = rtrim($config['api_url'], '/') . '/api/order/motif'; + + // Boundary für multipart + $boundary = wp_generate_password(24, false); + + // Multipart Body bauen + $body = ''; + + // orderNumber Feld + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"orderNumber\"\r\n\r\n"; + $body .= $order_number . "\r\n"; + + // Datei Feld + $body .= "--{$boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"motif\"; filename=\"" . basename($file['name']) . "\"\r\n"; + $body .= "Content-Type: " . ($real_type ?: 'application/octet-stream') . "\r\n\r\n"; + $body .= file_get_contents($file['tmp_name']) . "\r\n"; + $body .= "--{$boundary}--\r\n"; + + $args = [ + 'method' => 'POST', + 'timeout' => 60, + 'headers' => [ + 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, + ], + 'body' => $body, + ]; + + // API Token hinzufügen wenn vorhanden + if (!empty($config['api_token'])) { + $args['headers']['X-API-Token'] = $config['api_token']; + } + + $response = wp_remote_post($url, $args); + + if (is_wp_error($response)) { + return new WP_Error('backend_error', $response->get_error_message(), ['status' => 500]); + } + + $status_code = wp_remote_retrieve_response_code($response); + $response_body = wp_remote_retrieve_body($response); + $data = json_decode($response_body, true); + + if ($status_code >= 200 && $status_code < 300) { + return $data; + } + + return new WP_Error('backend_error', $data['error'] ?? $data['message'] ?? 'Upload fehlgeschlagen', ['status' => $status_code]); + } +} + +new Skrift_Konfigurator_API_Proxy(); diff --git a/skrift-configurator/readme.txt b/skrift-configurator/readme.txt new file mode 100644 index 0000000..e69de29 diff --git a/skrift-configurator/skrift-konfigurator.php b/skrift-configurator/skrift-konfigurator.php new file mode 100644 index 0000000..ee429d8 --- /dev/null +++ b/skrift-configurator/skrift-konfigurator.php @@ -0,0 +1,200 @@ +'; + } + + public function render_shortcode($atts = []): string { + wp_enqueue_style(self::SLUG); + wp_enqueue_script(self::SLUG); + + // Einstellungen aus DB holen + $settings = Skrift_Konfigurator_Admin_Settings::get_settings(); + $vouchers = Skrift_Konfigurator_Vouchers::get_vouchers(); + + // WICHTIG: Sicherstellen dass leere Arrays als Objekt {} encodiert werden, nicht als Array [] + if (empty($vouchers)) { + $vouchers = new stdClass(); + } + + // PayPal-Einstellungen für Frontend vorbereiten (ohne sensible Daten) + $paypal_frontend = []; + if (!empty($settings['paypal']['enabled'])) { + $mode = $settings['paypal']['mode'] ?? 'sandbox'; + $client_id = ($mode === 'live') + ? ($settings['paypal']['client_id_live'] ?? '') + : ($settings['paypal']['client_id_sandbox'] ?? ''); + + $paypal_frontend = [ + 'enabled' => true, + 'mode' => $mode, + 'client_id' => $client_id, + ]; + } + + ob_start(); + ?> + + +
    +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + +
    + + + + +
    +
    + + +
    + +
    +