Initial commit

This commit is contained in:
s4luorth
2026-02-07 13:04:04 +01:00
commit 5e0fceab15
82 changed files with 30348 additions and 0 deletions

View File

@@ -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:*)"
]
}
}

11
.vscode/sftp.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "skrift",
"host": "ae975.netcup.net",
"protocol": "ftp",
"port": 21,
"username": "skriftp",
"remotePath": "/",
"uploadOnSave": true,
"useTempFile": false,
"openSsh": false
}

View File

@@ -0,0 +1,6 @@
node_modules;
npm - debug.log.env.git.gitignore;
README.md;
test - scriptalizer.js;
cache;
output;

View File

@@ -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

7
Docker Backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.env
cache/
output/
npm-debug.log
.DS_Store
*.log

View File

@@ -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`

View File

@@ -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
```

31
Docker Backend/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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
<path transform="matrix(0.00846,0,0,-0.00846,75.6,120.5)" />
```
**Neu (mit Rotation):**
```xml
<path transform="matrix(0.00845,0.00004,-0.00004,-0.00845,75.6,120.5)" />
```
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 ✅

View File

@@ -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

461
Docker Backend/README.md Normal file
View File

@@ -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

View File

@@ -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
<svg xmlns="http://www.w3.org/2000/svg"
width="210mm"
height="297mm"
viewBox="0 0 793.8 1122.66">
<rect x="0" y="0" width="793.8" height="1122.66" fill="#FFFFFF" />
<path d="M1549 1857q-55 73 -124.5 125..."
transform="matrix(0.00846,0,0,-0.00846,75.6,120.5)"
stroke="#000000"
stroke-width="0.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round" />
<!-- ~2000 weitere Pfade für handgeschriebenen Text -->
</svg>
```
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

View File

@@ -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");
});
}

View File

@@ -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);
});
}

View File

@@ -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;
});
}

View File

@@ -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"
}
}
]
}
}
}

View File

@@ -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");
});
}

View File

@@ -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");
});
}

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "Skrift Backend API",
"type": "collection"
}

View File

@@ -0,0 +1,5 @@
vars {
baseUrl: http://localhost:4000
sessionId: test-session-{{$timestamp}}
orderNumber: SK-2026-01-15-001
}

252
Docker Backend/deploy.sh Normal file
View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 152 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -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);

1624
Docker Backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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);
});

View File

@@ -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
};

View File

@@ -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
};

View File

@@ -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,
};

View File

@@ -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>',
});
}
// 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,
};

View File

@@ -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 })
}
});
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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'
}
};

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 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
};

View File

@@ -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(/<font-face[\s\S]*?>/);
if (!fontFaceMatch) {
throw new Error(`Kein <font-face> 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 = /<glyph[\s\S]*?\/>/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
};

View File

@@ -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) => `<path d="${escapeXml(p.d)}"
transform="${p.transform}"
stroke="#000000"
stroke-width="0.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>`
)
.join('\n ');
const svg = `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm"
height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF" />
${pathElements}
</svg>`;
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) => `<path d="${escapeXml(p.d)}"
transform="${p.transform}"
stroke="#000000"
stroke-width="0.6"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>`
)
.join('\n ');
const svg = `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm"
height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF" />
${pathElements}
</svg>`;
return svg.trim();
}
module.exports = {
generateLetterSVG,
generateEnvelopeSVG
};

View File

@@ -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);
});

View File

@@ -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
};

View File

@@ -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<string[]>} - 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<string>} - 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,
};

View File

@@ -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"
}
]
}

View File

@@ -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"

View File

@@ -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"
}
}
]
}

View File

@@ -0,0 +1,4 @@
{
"sessionId": "test-complete-workflow",
"orderNumber": "SK-2026-01-02-001"
}

View File

@@ -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"
}
}
]
}
}

View File

@@ -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!"
}
}
]
}

View File

@@ -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": {}
}
]
}

View File

@@ -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('<Status>OK</Status>')) {
console.log('✅ Status: OK');
// Extract OutputText
const outputMatch = data.match(/<OutputText>([\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();

View File

@@ -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);

View File

@@ -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": {}
}
]
}

View File

@@ -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": {}
}
]
}

View File

@@ -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!** 🚀

268
n8n-email-template.html Normal file
View File

@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bestellbestätigung</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 1.5; color: #333333; background-color: #f5f5f5;">
<!-- Container -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f5f5f5;">
<tr>
<td style="padding: 20px 0;">
<!-- Email Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" align="center" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #1a365d; padding: 30px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 700;">Bestellbestätigung</h1>
</td>
</tr>
<!-- Greeting -->
<tr>
<td style="padding: 40px 40px 20px 40px;">
<p style="margin: 0 0 20px 0; font-size: 18px;">
Guten Tag {{ $json.body.customer_data.billing.firstName }} {{ $json.body.customer_data.billing.lastName }},
</p>
<p style="margin: 0 0 20px 0;">
vielen Dank für Ihre Bestellung bei Skrift. Wir haben Ihre Bestellung erhalten und werden diese schnellstmöglich bearbeiten.
</p>
</td>
</tr>
<!-- Order Number Box -->
<tr>
<td style="padding: 0 40px 30px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f0f7ff; border-radius: 8px; border-left: 4px solid #1a365d;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0; font-size: 14px; color: #666666;">Ihre Bestellnummer:</p>
<p style="margin: 5px 0 0 0; font-size: 24px; font-weight: 700; color: #1a365d;">{{ $json.body.order_number }}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding: 0 40px;">
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 0;">
</td>
</tr>
<!-- Order Details Section -->
<tr>
<td style="padding: 30px 40px 10px 40px;">
<h2 style="margin: 0 0 20px 0; font-size: 20px; color: #1a365d;">Ihre Bestellung</h2>
</td>
</tr>
<!-- Product Info -->
<tr>
<td style="padding: 0 40px 20px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="40%" style="padding: 8px 0; color: #666666;">Produkt:</td>
<td width="60%" style="padding: 8px 0; font-weight: 600;">{{ $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 }}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666;">Menge:</td>
<td style="padding: 8px 0; font-weight: 600;">{{ $json.body.quantity }} Stück</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666;">Format:</td>
<td style="padding: 8px 0; font-weight: 600;">{{ $json.body.format === 'a4' ? 'A4 Hochformat' : $json.body.format === 'a6p' ? 'A6 Hochformat' : $json.body.format === 'a6l' ? 'A6 Querformat' : $json.body.format }}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666;">Versand:</td>
<td style="padding: 8px 0; font-weight: 600;">{{ $json.body.shipping_mode === 'direct' ? 'Einzelversand durch Skrift' : 'Sammellieferung' }}</td>
</tr>
</table>
</td>
</tr>
<!-- Envelope Info -->
{{ $json.body.envelope ? `
<tr>
<td style="padding: 0 40px 20px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #fafafa; border-radius: 6px;">
<tr>
<td style="padding: 15px;">
<p style="margin: 0 0 10px 0; font-weight: 600; color: #1a365d;">Umschlag</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td width="40%" style="padding: 4px 0; color: #666666; font-size: 14px;">Format:</td>
<td width="60%" style="padding: 4px 0; font-size: 14px;">${ $json.body.format === 'a4' ? 'DIN Lang' : 'C6' }</td>
</tr>
<tr>
<td style="padding: 4px 0; color: #666666; font-size: 14px;">Beschriftung:</td>
<td style="padding: 4px 0; font-size: 14px;">${ $json.body.envelope_mode === 'recipientData' ? 'Empfängeradresse' : $json.body.envelope_mode === 'customText' ? 'Individueller Text' : 'Keine' }</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
` : '' }}
<!-- Divider -->
<tr>
<td style="padding: 10px 40px;">
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 0;">
</td>
</tr>
<!-- Price Section -->
<tr>
<td style="padding: 20px 40px 10px 40px;">
<h2 style="margin: 0 0 20px 0; font-size: 20px; color: #1a365d;">Preisübersicht</h2>
</td>
</tr>
<tr>
<td style="padding: 0 40px 20px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<!-- Zwischensumme -->
<tr>
<td style="padding: 8px 0; color: #666666;">Zwischensumme (netto):</td>
<td style="padding: 8px 0; text-align: right;">{{ $json.body.quote.subtotalNet.toFixed(2).replace('.', ',') }} {{ $json.body.quote.currency }}</td>
</tr>
<!-- MwSt -->
<tr>
<td style="padding: 8px 0; color: #666666;">MwSt. ({{ Math.round($json.body.quote.vatRate * 100) }}%):</td>
<td style="padding: 8px 0; text-align: right;">{{ $json.body.quote.vatAmount.toFixed(2).replace('.', ',') }} {{ $json.body.quote.currency }}</td>
</tr>
<!-- Gutschein -->
{{ $json.body.quote.voucher ? `
<tr>
<td style="padding: 8px 0; color: #276749;">Gutschein (${ $json.body.quote.voucher.code }):</td>
<td style="padding: 8px 0; text-align: right; color: #276749;">-${ $json.body.quote.discountAmount.toFixed(2).replace('.', ',') } ${ $json.body.quote.currency }</td>
</tr>
` : '' }}
<!-- Trennlinie -->
<tr>
<td colspan="2" style="padding: 10px 0;">
<hr style="border: none; border-top: 2px solid #1a365d; margin: 0;">
</td>
</tr>
<!-- Gesamtbetrag -->
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 700; color: #1a365d;">Gesamtbetrag (brutto):</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 700; color: #1a365d;">{{ $json.body.quote.totalGross.toFixed(2).replace('.', ',') }} {{ $json.body.quote.currency }}</td>
</tr>
</table>
</td>
</tr>
<!-- Divider -->
<tr>
<td style="padding: 10px 40px;">
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 0;">
</td>
</tr>
<!-- Billing Address -->
<tr>
<td style="padding: 20px 40px 10px 40px;">
<h2 style="margin: 0 0 20px 0; font-size: 20px; color: #1a365d;">Rechnungsadresse</h2>
</td>
</tr>
<tr>
<td style="padding: 0 40px 20px 40px;">
<p style="margin: 0; line-height: 1.6;">
{{ $json.body.customer_data.billing.company ? `<strong>${$json.body.customer_data.billing.company}</strong><br>` : '' }}
{{ $json.body.customer_data.billing.firstName }} {{ $json.body.customer_data.billing.lastName }}<br>
{{ $json.body.customer_data.billing.street }}{{ $json.body.customer_data.billing.houseNumber ? ' ' + $json.body.customer_data.billing.houseNumber : '' }}<br>
{{ $json.body.customer_data.billing.zip }} {{ $json.body.customer_data.billing.city }}<br>
{{ $json.body.customer_data.billing.country }}<br>
<br>
E-Mail: {{ $json.body.customer_data.billing.email }}<br>
Telefon: {{ $json.body.customer_data.billing.phone }}
</p>
</td>
</tr>
<!-- Shipping Address (falls abweichend) -->
{{ $json.body.customer_data.shippingDifferent ? `
<tr>
<td style="padding: 0 40px 10px 40px;">
<h2 style="margin: 0 0 20px 0; font-size: 20px; color: #1a365d;">Lieferadresse</h2>
</td>
</tr>
<tr>
<td style="padding: 0 40px 20px 40px;">
<p style="margin: 0; line-height: 1.6;">
${ $json.body.customer_data.shipping.company ? '<strong>' + $json.body.customer_data.shipping.company + '</strong><br>' : '' }
${ $json.body.customer_data.shipping.firstName } ${ $json.body.customer_data.shipping.lastName }<br>
${ $json.body.customer_data.shipping.street }${ $json.body.customer_data.shipping.houseNumber ? ' ' + $json.body.customer_data.shipping.houseNumber : '' }<br>
${ $json.body.customer_data.shipping.zip } ${ $json.body.customer_data.shipping.city }<br>
${ $json.body.customer_data.shipping.country }
</p>
</td>
</tr>
` : '' }}
<!-- Divider -->
<tr>
<td style="padding: 10px 40px;">
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 0;">
</td>
</tr>
<!-- Next Steps -->
<tr>
<td style="padding: 20px 40px;">
<h2 style="margin: 0 0 15px 0; font-size: 20px; color: #1a365d;">Wie geht es weiter?</h2>
<ol style="margin: 0; padding: 0 0 0 20px; line-height: 1.8;">
<li>Wir prüfen Ihre Bestellung und bereiten die Produktion vor.</li>
<li>Nach Fertigstellung erhalten Sie eine Versandbestätigung.</li>
</ol>
</td>
</tr>
<!-- Contact Info -->
<tr>
<td style="padding: 20px 40px 30px 40px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f5f5f5; border-radius: 6px;">
<tr>
<td style="padding: 20px; text-align: center;">
<p style="margin: 0 0 10px 0; font-weight: 600;">Fragen zu Ihrer Bestellung?</p>
<p style="margin: 0; font-size: 14px; color: #666666;">
Kontaktieren Sie uns unter<br>
<a href="mailto:hello@skrift.de" style="color: #1a365d; text-decoration: none;">hello@skrift.de</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f5f5f5; padding: 30px 40px; text-align: center; border-top: 1px solid #e0e0e0;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
Mit freundlichen Grüßen<br>
<strong>Ihr Skrift-Team</strong>
</p>
<p style="margin: 20px 0 0 0; font-size: 12px; color: #999999;">
Skrift | Hundscheiderweg 4 | 66679 Losheim am See<br>
<a href="https://skrift.de" style="color: #1a365d; text-decoration: none;">www.skrift.de</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

11
skrift-configurator/.vscode/sftp.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "skrift",
"host": "ae975.netcup.net",
"protocol": "ftp",
"port": 21,
"username": "skrift",
"remotePath": "/",
"uploadOnSave": true,
"useTempFile": false,
"openSsh": false
}

View File

@@ -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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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');
});
})();

View File

@@ -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,
};

View File

@@ -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 = `
<p style="margin-bottom: 15px;">Die Vorschau konnte nicht generiert werden.</p>
<p style="color: #999; font-size: 13px;">Bitte versuchen Sie es erneut oder kontaktieren Sie uns bei anhaltenden Problemen.</p>
`;
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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,110 @@
<?php
/**
* Schnelle Datenbank-Prüfung
* Aufruf: /wp-content/plugins/skrift-konfigurator/check-db.php
*/
// WordPress laden
require_once('../../../wp-load.php');
// Sicherheit
if (!current_user_can('manage_options')) {
die('Keine Berechtigung');
}
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Gutschein DB Check</title>
<style>
body { font-family: monospace; padding: 20px; background: #f5f5f5; }
.box { background: white; padding: 20px; margin: 10px 0; border: 2px solid #333; }
.error { background: #ffebee; border-color: #c62828; }
.success { background: #e8f5e9; border-color: #2e7d32; }
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
h2 { margin-top: 0; }
</style>
</head>
<body>
<h1>🔍 Gutschein Datenbank Check</h1>
<?php
// 1. Direkt aus DB
$vouchers = get_option('skrift_konfigurator_vouchers', []);
$count = count($vouchers);
?>
<div class="box <?php echo $count > 0 ? 'success' : 'error'; ?>">
<h2>1. Datenbank Status</h2>
<p><strong>Anzahl Gutscheine:</strong> <?php echo $count; ?></p>
<?php if ($count === 0): ?>
<p style="color: #c62828;">⚠️ <strong>KEINE GUTSCHEINE IN DER DATENBANK!</strong></p>
<?php else: ?>
<p style="color: #2e7d32;">✅ Gutscheine gefunden!</p>
<?php endif; ?>
</div>
<div class="box">
<h2>2. Rohe Daten (PHP)</h2>
<pre><?php var_dump($vouchers); ?></pre>
</div>
<div class="box">
<h2>3. JSON Encoding (wie im Frontend)</h2>
<pre><?php echo wp_json_encode($vouchers, JSON_PRETTY_PRINT); ?></pre>
</div>
<div class="box">
<h2>4. JavaScript Test</h2>
<script>
const vouchers = <?php echo wp_json_encode($vouchers); ?>;
console.log('Vouchers:', vouchers);
console.log('Type:', Array.isArray(vouchers) ? 'ARRAY ❌' : 'OBJECT ✅');
console.log('Keys:', Object.keys(vouchers));
console.log('Count:', Object.keys(vouchers).length);
</script>
<p>Öffnen Sie die Browser Console (F12) für JavaScript-Output</p>
</div>
<?php if ($count === 0): ?>
<div class="box error">
<h2>⚠️ Lösung: Gutscheine erstellen</h2>
<p>Es sind keine Gutscheine in der Datenbank. Bitte:</p>
<ol>
<li>Gehen Sie zu: <a href="<?php echo admin_url('options-general.php?page=skrift-vouchers'); ?>">Gutschein-Verwaltung</a></li>
<li>Erstellen Sie einen Test-Gutschein (z.B. Code: TEST10, Typ: Prozent, Wert: 10)</li>
<li>Oder führen Sie <code>create-test-voucher.php</code> aus</li>
</ol>
</div>
<?php endif; ?>
<div class="box">
<h2>5. Schnell-Fix: Test-Gutschein erstellen</h2>
<form method="post">
<button type="submit" name="create_test" style="padding: 10px 20px; font-size: 16px; cursor: pointer;">
🚀 Test-Gutschein "TEST10" jetzt erstellen
</button>
</form>
<?php
if (isset($_POST['create_test'])) {
$vouchers['TEST10'] = [
'code' => 'TEST10',
'type' => 'percent',
'value' => 10,
'expiry_date' => '',
'usage_limit' => 0,
'usage_count' => 0,
];
update_option('skrift_konfigurator_vouchers', $vouchers);
echo '<p style="color: #2e7d32; font-weight: bold;">✅ Gutschein TEST10 wurde erstellt! Seite neu laden...</p>';
echo '<script>setTimeout(() => location.reload(), 1500);</script>';
}
?>
</div>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<?php
/**
* EINMALIG AUSFÜHREN: Test-Gutschein erstellen
*
* Anleitung:
* 1. Diese Datei in den Plugin-Ordner legen
* 2. Im Browser aufrufen: https://ihre-domain.de/wp-content/plugins/skrift-konfigurator/create-test-voucher.php
* 3. Nach erfolgreicher Ausführung diese Datei LÖSCHEN
*/
// WordPress laden
require_once('../../../wp-load.php');
// Sicherheit: Nur für Admins
if (!current_user_can('manage_options')) {
die('Keine Berechtigung');
}
// Test-Gutscheine erstellen
$vouchers = get_option('skrift_konfigurator_vouchers', []);
// Test-Gutschein 1: 10% Rabatt
$vouchers['TEST10'] = [
'code' => '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 '<h1>✓ Test-Gutscheine erfolgreich erstellt!</h1>';
echo '<ul>';
echo '<li><strong>TEST10</strong> - 10% Rabatt (unbegrenzt)</li>';
echo '<li><strong>SAVE5</strong> - 5,00€ Rabatt (unbegrenzt)</li>';
echo '<li><strong>WELCOME20</strong> - 20% Rabatt (30 Tage gültig, max. 10x)</li>';
echo '</ul>';
echo '<p><a href="' . admin_url('options-general.php?page=skrift-vouchers') . '">→ Gutscheine im Backend anzeigen</a></p>';
echo '<p style="color: red;"><strong>WICHTIG: Bitte löschen Sie diese Datei jetzt aus Sicherheitsgründen!</strong></p>';

View File

@@ -0,0 +1,72 @@
<?php
/**
* DEBUG: Gutscheine prüfen
* Aufruf: /wp-content/plugins/skrift-konfigurator/debug-vouchers.php
*/
// WordPress laden
require_once('../../../wp-load.php');
// Sicherheit: Nur für Admins
if (!current_user_can('manage_options')) {
die('Keine Berechtigung');
}
echo '<h1>Gutschein Debug</h1>';
echo '<style>pre { background: #f5f5f5; padding: 15px; border: 1px solid #ddd; }</style>';
// 1. Direkt aus Datenbank lesen
$vouchers_db = get_option('skrift_konfigurator_vouchers', []);
echo '<h2>1. Direkt aus Datenbank (get_option)</h2>';
echo '<pre>';
print_r($vouchers_db);
echo '</pre>';
// 2. Über die Klasse
require_once plugin_dir_path(__FILE__) . 'includes/admin-vouchers.php';
$vouchers_class = Skrift_Konfigurator_Vouchers::get_vouchers();
echo '<h2>2. Über Klassen-Methode get_vouchers()</h2>';
echo '<pre>';
print_r($vouchers_class);
echo '</pre>';
// 3. JSON-Encoding prüfen (wie wp_localize_script es macht)
$vouchers_json = json_encode($vouchers_class);
echo '<h2>3. JSON-Encoded (wie wp_localize_script)</h2>';
echo '<pre>';
echo htmlspecialchars($vouchers_json);
echo '</pre>';
// 4. Zurück decodiert
$vouchers_decoded = json_decode($vouchers_json, true);
echo '<h2>4. JSON wieder decodiert</h2>';
echo '<pre>';
print_r($vouchers_decoded);
echo '</pre>';
// 5. Test: Ist es ein assoziatives Array oder Objekt?
echo '<h2>5. Datentyp-Analyse</h2>';
echo '<pre>';
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 '</pre>';
// 6. Simuliere wp_localize_script
echo '<h2>6. Simuliertes wp_localize_script Output</h2>';
echo '<script>';
echo "\n";
echo 'var SkriftConfigurator = {';
echo "\n";
echo ' "version": "0.3.0",';
echo "\n";
echo ' "vouchers": ' . json_encode($vouchers_class);
echo "\n";
echo '};';
echo "\n";
echo 'console.log("Vouchers from simulated wp_localize_script:", SkriftConfigurator.vouchers);';
echo "\n";
echo '</script>';
echo '<p><strong>Öffnen Sie die Browser-Console, um das simulierte Output zu sehen!</strong></p>';

View File

@@ -0,0 +1,186 @@
<?php
/**
* Bestellnummer-Verwaltung für Skrift Konfigurator
* Generiert fortlaufende Bestellnummern im Format: S-JAHR-MONAT-TAG-XXX
*/
if (!defined('ABSPATH')) { exit; }
final class Skrift_Konfigurator_Orders {
const COUNTER_OPTION_KEY = 'skrift_konfigurator_order_counter';
const ORDERS_OPTION_KEY = 'skrift_konfigurator_orders';
public function __construct() {
add_action('rest_api_init', [$this, 'register_rest_routes']);
}
/**
* REST API Routen registrieren
*/
public function register_rest_routes() {
// Neue Bestellnummer generieren
register_rest_route('skrift/v1', '/order/generate-number', [
'methods' => '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();

View File

@@ -0,0 +1,943 @@
<?php
/**
* Admin Settings für Skrift Konfigurator
* Verwaltung von Produkten, Preisen und Beschreibungen
*/
if (!defined('ABSPATH')) { exit; }
class Skrift_Konfigurator_Admin_Settings {
const OPTION_KEY = 'skrift_konfigurator_settings';
public function __construct() {
add_action('admin_menu', [$this, 'add_admin_menu']);
add_action('admin_init', [$this, 'register_settings']);
}
public function add_admin_menu(): void {
add_options_page(
'Skrift Konfigurator Einstellungen',
'Skrift Konfigurator',
'manage_options',
'skrift-konfigurator',
[$this, 'render_settings_page']
);
}
public function register_settings(): void {
register_setting('skrift_konfigurator', self::OPTION_KEY, [
'type' => '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();
?>
<style>
.sk-admin-wrap { max-width: 1200px; }
.sk-admin-section { background: #fff; padding: 20px; margin: 20px 0; border: 1px solid #ccd0d4; box-shadow: 0 1px 1px rgba(0,0,0,.04); }
.sk-admin-section h2 { margin-top: 0; padding-bottom: 10px; border-bottom: 1px solid #eee; }
.sk-product-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 20px; margin-top: 20px; }
.sk-product-card { background: #f9f9f9; padding: 15px; border: 1px solid #ddd; border-radius: 4px; }
.sk-product-card h3 { margin-top: 0; color: #0073aa; font-size: 16px; }
.sk-field-row { margin-bottom: 15px; }
.sk-field-row label { display: block; font-weight: 600; margin-bottom: 5px; }
.sk-field-row input[type="text"], .sk-field-row textarea { width: 100%; }
.sk-field-row input[type="number"] { width: 120px; }
.sk-price-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; margin-top: 15px; }
.sk-price-item { background: #f9f9f9; padding: 12px; border-left: 3px solid #0073aa; }
.sk-price-item label { display: flex; justify-content: space-between; align-items: center; }
.sk-price-item strong { color: #23282d; }
.sk-multiplier-table { width: 100%; border-collapse: collapse; margin-top: 10px; }
.sk-multiplier-table th, .sk-multiplier-table td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
.sk-multiplier-table th { background: #f0f0f1; font-weight: 600; }
.sk-multiplier-table input[type="number"] { width: 100px; }
</style>
<div class="wrap sk-admin-wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields('skrift_konfigurator');
do_settings_sections('skrift_konfigurator');
?>
<!-- Produkte -->
<div class="sk-admin-section">
<h2>📦 Produkte</h2>
<p>Verwalten Sie Namen, Beschreibungen und Startpreise für alle Produkte.</p>
<div class="sk-product-grid">
<?php $this->render_product_card('businessbriefe', 'Businessbriefe', $settings); ?>
<?php $this->render_product_card('business-postkarten', 'Business Postkarten', $settings); ?>
<?php $this->render_product_card('follow-ups', 'Follow-ups', $settings); ?>
<?php $this->render_product_card('einladungen', 'Einladungen', $settings); ?>
<?php $this->render_product_card('private-briefe', 'Private Briefe', $settings); ?>
</div>
</div>
<!-- Format Aufpreise -->
<div class="sk-admin-section">
<h2>📐 Format Aufpreise</h2>
<p>Aufpreise wenn bei bestimmten Produkten das Format gewechselt wird.</p>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>A4 Aufpreis (Follow-ups/Einladungen)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][a4_upgrade_surcharge]"
value="<?php echo esc_attr($settings['prices']['a4_upgrade_surcharge'] ?? '0.50'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Aufpreis pro Stück wenn bei Postkarten oder Einladungen auf A4 gewechselt wird</small>
</div>
</div>
</div>
<!-- Versand & Umschlag -->
<div class="sk-admin-section">
<h2>🚚 Versand & Umschlag</h2>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Porto Inland / Deutschland (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][shipping_domestic]"
value="<?php echo esc_attr($settings['prices']['shipping_domestic'] ?? '0.95'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Portokosten für Versand innerhalb Deutschlands (0% MwSt.)</small>
</div>
<div class="sk-price-item">
<label>
<strong>Porto Ausland (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][shipping_international]"
value="<?php echo esc_attr($settings['prices']['shipping_international'] ?? '1.25'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Portokosten für Auslandsversand (0% MwSt.)</small>
</div>
<div class="sk-price-item">
<label>
<strong>Serviceaufschlag Versand (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][shipping_service]"
value="<?php echo esc_attr($settings['prices']['shipping_service'] ?? '0.95'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Service-Aufschlag für Direktversand (19% MwSt.)</small>
</div>
<div class="sk-price-item">
<label>
<strong>Bulkversand (einmalig)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][shipping_bulk]"
value="<?php echo esc_attr($settings['prices']['shipping_bulk'] ?? '4.95'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>Kuvert (Grundpreis pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][envelope_base]"
value="<?php echo esc_attr($settings['prices']['envelope_base'] ?? '0.50'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Grundpreis für Kuvert</small>
</div>
<div class="sk-price-item">
<label>
<strong>Aufschlag Beschriftung (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][envelope_labeling]"
value="<?php echo esc_attr($settings['prices']['envelope_labeling'] ?? '0.50'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Aufschlag für Beschriftung des Umschlags (Empfängeradresse oder individueller Text)</small>
</div>
</div>
</div>
<!-- Zusatzleistungen -->
<div class="sk-admin-section">
<h2>✨ Zusatzleistungen</h2>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Motiv Upload <span style="color: #0073aa; font-size: 12px;">(einmalig)</span></strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][motif_upload]"
value="<?php echo esc_attr($settings['prices']['motif_upload'] ?? '0.30'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>Bedruckte Karten zusenden (pro Stück)</strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][motif_printed]"
value="<?php echo esc_attr($settings['prices']['motif_printed'] ?? '0.00'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>Designservice <span style="color: #0073aa; font-size: 12px;">(einmalig)</span></strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][motif_design]"
value="<?php echo esc_attr($settings['prices']['motif_design'] ?? '0.00'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>Textservice <span style="color: #0073aa; font-size: 12px;">(einmalig)</span></strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][textservice]"
value="<?php echo esc_attr($settings['prices']['textservice'] ?? '0.00'); ?>"> €
</span>
</label>
</div>
<div class="sk-price-item">
<label>
<strong>API-Anbindung <span style="color: #0073aa; font-size: 12px;">(einmalig)</span></strong>
<span>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][api_connection]"
value="<?php echo esc_attr($settings['prices']['api_connection'] ?? '250.00'); ?>"> €
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Einmalige Einrichtungsgebühr für die API-Anbindung</small>
</div>
</div>
</div>
<!-- Follow-ups Mengenstaffel -->
<div class="sk-admin-section">
<h2>📊 Follow-ups Preis-Multiplikatoren</h2>
<p>Die Gesamtkosten pro Schriftstück werden mit diesen Multiplikatoren je nach Menge multipliziert.</p>
<table class="sk-multiplier-table">
<thead>
<tr>
<th>Menge</th>
<th>Multiplikator</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>5 - 49 Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_5_49]"
value="<?php echo esc_attr($settings['prices']['followup_mult_5_49'] ?? '2.0'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
<tr>
<td><strong>50 - 199 Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_50_199]"
value="<?php echo esc_attr($settings['prices']['followup_mult_50_199'] ?? '1.7'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
<tr>
<td><strong>200 - 499 Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_200_499]"
value="<?php echo esc_attr($settings['prices']['followup_mult_200_499'] ?? '1.4'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
<tr>
<td><strong>500 - 999 Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_500_999]"
value="<?php echo esc_attr($settings['prices']['followup_mult_500_999'] ?? '1.2'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
<tr>
<td><strong>1000+ Stück</strong></td>
<td>
<input type="number" step="0.1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][followup_mult_1000_plus]"
value="<?php echo esc_attr($settings['prices']['followup_mult_1000_plus'] ?? '1.0'); ?>">
<small style="margin-left: 10px; color: #666;">×</small>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Steuern -->
<div class="sk-admin-section">
<h2>📋 Steuern</h2>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Mehrwertsteuersatz (%)</strong>
<span>
<input type="number" step="0.01" min="0" max="100"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[prices][tax_rate]"
value="<?php echo esc_attr($settings['prices']['tax_rate'] ?? '19'); ?>"> %
</span>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Gilt für alle Positionen inkl. Versand</small>
</div>
</div>
</div>
<!-- Dynamische Preisberechnung -->
<div class="sk-admin-section">
<h2>🧮 Dynamische Preisberechnung</h2>
<p>Konfigurieren Sie die dynamische Preisberechnung basierend auf Mengen. Die Formeln unterstützen Platzhalter wie <code>%qty%</code> (aktuelle Menge), <code>%norm_b%</code> (Normalpreis Menge Business), <code>%mind_b%</code> (Mind. Menge Business), <code>%norm_p%</code> (Normalpreis Menge Privat), <code>%mind_p%</code> (Mind. Menge Privat).</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 30px; margin-top: 20px;">
<!-- Business -->
<div style="background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-radius: 4px;">
<h3 style="margin-top: 0; color: #0073aa;">Business</h3>
<div class="sk-field-row">
<label><strong>Mindestmenge Business</strong></label>
<input type="number" step="1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][business_min_quantity]"
value="<?php echo esc_attr($settings['dynamic_pricing']['business_min_quantity'] ?? '50'); ?>"
style="width: 150px;">
<small style="display: block; margin-top: 5px; color: #666;">Minimale Menge für Business-Bestellungen (außer Follow-ups)</small>
</div>
<div class="sk-field-row" style="margin-top: 15px;">
<label><strong>Normalpreis Menge Business</strong></label>
<input type="number" step="1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][business_normal_quantity]"
value="<?php echo esc_attr($settings['dynamic_pricing']['business_normal_quantity'] ?? '200'); ?>"
style="width: 150px;">
<small style="display: block; margin-top: 5px; color: #666;">Ab dieser Menge gilt der Normalpreis (Multiplikator = 1)</small>
</div>
<div class="sk-field-row" style="margin-top: 15px;">
<label><strong>Dynamische Formel Business</strong></label>
<textarea
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][business_formula]"
rows="8"
style="width: 100%; font-family: monospace; font-size: 12px;"
placeholder="(%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))"><?php echo esc_textarea($settings['dynamic_pricing']['business_formula'] ?? ''); ?></textarea>
<small style="display: block; margin-top: 5px; color: #666;">
<strong>JavaScript-Formel zur Berechnung des Preis-Multiplikators.</strong><br>
Platzhalter verwenden: <code>%qty%</code> für aktuelle Menge, <code>%norm_b%</code> für Normalpreis-Menge Business, <code>%mind_b%</code> für Mindestmenge Business.<br>
Beispiel: <code>(%qty% >= %norm_b%) ? 1 : (2 - Math.sqrt(%qty% / %norm_b%))</code><br>
Dies bedeutet: Wenn die Menge >= Normalpreis-Menge ist, dann Multiplikator = 1, sonst dynamische Berechnung.
</small>
</div>
</div>
<!-- Privat -->
<div style="background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-radius: 4px;">
<h3 style="margin-top: 0; color: #0073aa;">Privat</h3>
<div class="sk-field-row">
<label><strong>Mindestmenge Privat</strong></label>
<input type="number" step="1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][private_min_quantity]"
value="<?php echo esc_attr($settings['dynamic_pricing']['private_min_quantity'] ?? '10'); ?>"
style="width: 150px;">
<small style="display: block; margin-top: 5px; color: #666;">Minimale Menge für Private Bestellungen</small>
</div>
<div class="sk-field-row" style="margin-top: 15px;">
<label><strong>Normalpreis Menge Privat</strong></label>
<input type="number" step="1" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][private_normal_quantity]"
value="<?php echo esc_attr($settings['dynamic_pricing']['private_normal_quantity'] ?? '50'); ?>"
style="width: 150px;">
<small style="display: block; margin-top: 5px; color: #666;">Ab dieser Menge gilt der Normalpreis (Multiplikator = 1)</small>
</div>
<div class="sk-field-row" style="margin-top: 15px;">
<label><strong>Dynamische Formel Privat</strong></label>
<textarea
name="<?php echo esc_attr(self::OPTION_KEY); ?>[dynamic_pricing][private_formula]"
rows="8"
style="width: 100%; font-family: monospace; font-size: 12px;"
placeholder="(%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))"><?php echo esc_textarea($settings['dynamic_pricing']['private_formula'] ?? ''); ?></textarea>
<small style="display: block; margin-top: 5px; color: #666;">
<strong>JavaScript-Formel zur Berechnung des Preis-Multiplikators.</strong><br>
Platzhalter verwenden: <code>%qty%</code> für aktuelle Menge, <code>%norm_p%</code> für Normalpreis-Menge Privat, <code>%mind_p%</code> für Mindestmenge Privat.<br>
Beispiel: <code>(%qty% >= %norm_p%) ? 1 : (2 - Math.sqrt(%qty% / %norm_p%))</code><br>
Dies bedeutet: Wenn die Menge >= Normalpreis-Menge ist, dann Multiplikator = 1, sonst dynamische Berechnung.
</small>
</div>
</div>
</div>
<div style="margin-top: 20px; padding: 15px; background: #fff3cd; border-left: 4px solid #ffc107;">
<strong>Verfügbare Platzhalter:</strong>
<ul style="margin: 10px 0 0 20px;">
<li><code>%qty%</code> - Aktuelle Menge (eingegebene Stückzahl)</li>
<li><code>%norm_b%</code> - Normalpreis Menge Business</li>
<li><code>%mind_b%</code> - Mindestmenge Business</li>
<li><code>%norm_p%</code> - Normalpreis Menge Privat</li>
<li><code>%mind_p%</code> - Mindestmenge Privat</li>
</ul>
</div>
</div>
<!-- Backend-Verbindung -->
<div class="sk-admin-section">
<h2>🔌 Backend-Verbindung</h2>
<p>Konfigurieren Sie die Verbindung zum Backend-System für erweiterte Funktionen.</p>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>API URL / Domain</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][api_url]"
value="<?php echo esc_attr($settings['backend_connection']['api_url'] ?? ''); ?>"
placeholder="https://api.example.com"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">Basis-URL des Backend-Systems (z.B. https://api.example.com)</small>
</div>
<div class="sk-price-item">
<label>
<strong>API Token / Authentifizierung</strong>
</label>
<input type="text"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][api_token]"
value="<?php echo esc_attr($settings['backend_connection']['api_token'] ?? ''); ?>"
placeholder="sk_live_..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
<small style="display: block; margin-top: 5px; color: #666;">Authentifizierungs-Token für API-Zugriff</small>
</div>
<div class="sk-price-item">
<label>
<strong>Webhook URL Geschäftskunden (B2B)</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][webhook_url_business]"
value="<?php echo esc_attr($settings['backend_connection']['webhook_url_business'] ?? ''); ?>"
placeholder="https://api.example.com/webhooks/order-business"
style="width: 100%; margin-top: 8px; font-family: monospace;">
<small style="display: block; margin-top: 5px; color: #666;">Webhook wird nach Klick auf "Jetzt kostenpflichtig bestellen" für Geschäftskunden aufgerufen</small>
</div>
<div class="sk-price-item">
<label>
<strong>Webhook URL Privatkunden (B2C)</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][webhook_url_private]"
value="<?php echo esc_attr($settings['backend_connection']['webhook_url_private'] ?? ''); ?>"
placeholder="https://api.example.com/webhooks/order-private"
style="width: 100%; margin-top: 8px; font-family: monospace;">
<small style="display: block; margin-top: 5px; color: #666;">Webhook wird nach erfolgreicher PayPal-Zahlung für Privatkunden aufgerufen</small>
</div>
<div class="sk-price-item">
<label>
<strong>Redirect URL Geschäftskunden</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][redirect_url_business]"
value="<?php echo esc_attr($settings['backend_connection']['redirect_url_business'] ?? ''); ?>"
placeholder="https://example.com/danke-business"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">Weiterleitung nach Bestellung für Geschäftskunden (nach Klick auf "Jetzt kostenpflichtig bestellen")</small>
</div>
<div class="sk-price-item">
<label>
<strong>Redirect URL Privatkunden</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[backend_connection][redirect_url_private]"
value="<?php echo esc_attr($settings['backend_connection']['redirect_url_private'] ?? ''); ?>"
placeholder="https://example.com/danke-privat"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">Weiterleitung nach erfolgreicher PayPal-Zahlung für Privatkunden</small>
</div>
</div>
</div>
<!-- REST API Security -->
<div class="sk-admin-section">
<h2>🔐 REST API Sicherheit</h2>
<p>Konfigurieren Sie einen API-Key für die REST-API-Endpunkte. Dieser Key muss im Header <code>X-Skrift-API-Key</code> mitgesendet werden.</p>
<div class="sk-price-grid">
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label>
<strong>API Key</strong>
</label>
<div style="display: flex; gap: 10px; margin-top: 8px;">
<input type="text" id="sk-api-key-input"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[api_security][api_key]"
value="<?php echo esc_attr($settings['api_security']['api_key'] ?? ''); ?>"
placeholder="sk_api_..."
style="flex: 1; font-family: monospace;">
<button type="button" class="button" onclick="document.getElementById('sk-api-key-input').value = 'sk_api_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);">
Generieren
</button>
</div>
<small style="display: block; margin-top: 5px; color: #666;">Leer lassen um API-Key-Prüfung zu deaktivieren (nicht empfohlen für Produktion)</small>
</div>
</div>
</div>
<!-- Schriftmuster Fallback -->
<div class="sk-admin-section">
<h2>✍️ Schriftmuster (Vorschau-Fallback)</h2>
<p>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.</p>
<div class="sk-price-grid">
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label>
<strong>Schriftmuster-URL</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[font_sample][url]"
value="<?php echo esc_attr($settings['font_sample']['url'] ?? ''); ?>"
placeholder="https://example.com/schriftmuster"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">URL zur Schriftmuster-Seite (wird in neuem Tab geöffnet)</small>
</div>
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label>
<strong>Platzhalter-Hilfe URL</strong>
</label>
<input type="url"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[font_sample][placeholder_help_url]"
value="<?php echo esc_attr($settings['font_sample']['placeholder_help_url'] ?? ''); ?>"
placeholder="https://example.com/platzhalter-hilfe"
style="width: 100%; margin-top: 8px;">
<small style="display: block; margin-top: 5px; color: #666;">URL zur Platzhalter-Hilfeseite (wird bei Platzhalter-Infotexten als Link angezeigt)</small>
</div>
</div>
</div>
<!-- PayPal-Verbindung -->
<div class="sk-admin-section">
<h2>💳 PayPal-Verbindung (nur Privatkunden)</h2>
<p>Konfigurieren Sie die PayPal-Zahlungsintegration. PayPal ist nur für Privatkunden aktiviert.</p>
<div class="sk-price-grid">
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][enabled]"
value="1"
<?php checked($settings['paypal']['enabled'] ?? false); ?>>
<strong>PayPal-Zahlung aktivieren</strong>
</label>
<small style="display: block; margin-top: 5px; color: #666;">Aktiviert PayPal als Zahlungsoption für Privatkunden</small>
</div>
<div class="sk-price-item" style="grid-column: 1 / -1;">
<label>
<strong>Modus</strong>
</label>
<select
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][mode]"
style="width: 200px; margin-top: 8px;">
<option value="sandbox" <?php selected(($settings['paypal']['mode'] ?? 'sandbox'), 'sandbox'); ?>>Sandbox (Test)</option>
<option value="live" <?php selected(($settings['paypal']['mode'] ?? 'sandbox'), 'live'); ?>>Live (Produktion)</option>
</select>
<small style="display: block; margin-top: 5px; color: #666;">Sandbox für Tests, Live für echte Zahlungen</small>
</div>
</div>
<h3 style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd;">Sandbox-Zugangsdaten (Test)</h3>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Client ID (Sandbox)</strong>
</label>
<input type="text"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][client_id_sandbox]"
value="<?php echo esc_attr($settings['paypal']['client_id_sandbox'] ?? ''); ?>"
placeholder="AZn4..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
</div>
<div class="sk-price-item">
<label>
<strong>Client Secret (Sandbox)</strong>
</label>
<input type="password"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][client_secret_sandbox]"
value="<?php echo esc_attr($settings['paypal']['client_secret_sandbox'] ?? ''); ?>"
placeholder="EL3..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
</div>
</div>
<h3 style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd;">Live-Zugangsdaten (Produktion)</h3>
<div class="sk-price-grid">
<div class="sk-price-item">
<label>
<strong>Client ID (Live)</strong>
</label>
<input type="text"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][client_id_live]"
value="<?php echo esc_attr($settings['paypal']['client_id_live'] ?? ''); ?>"
placeholder="AZn4..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
</div>
<div class="sk-price-item">
<label>
<strong>Client Secret (Live)</strong>
</label>
<input type="password"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[paypal][client_secret_live]"
value="<?php echo esc_attr($settings['paypal']['client_secret_live'] ?? ''); ?>"
placeholder="EL3..."
style="width: 100%; margin-top: 8px; font-family: monospace;">
</div>
</div>
<div style="margin-top: 20px; padding: 15px; background: #e7f3ff; border-left: 4px solid #2196f3;">
<strong>Hinweis:</strong> Um PayPal zu aktivieren, benötigen Sie ein PayPal Business-Konto.
Sie erhalten die API-Zugangsdaten im <a href="https://developer.paypal.com/dashboard/applications/" target="_blank">PayPal Developer Dashboard</a>.
</div>
</div>
<?php submit_button('Einstellungen speichern'); ?>
</form>
<!-- URL Parameter Dokumentation -->
<div class="sk-section" style="margin-top: 40px; padding: 20px; background: #f9f9f9; border: 1px solid #ddd; border-radius: 8px;">
<h2 style="margin-top: 0;">URL-Parameter</h2>
<p>Der Konfigurator unterstützt folgende URL-Parameter:</p>
<table class="widefat" style="margin-top: 15px;">
<thead>
<tr>
<th style="width: 200px;">Parameter</th>
<th style="width: 200px;">Werte</th>
<th>Beschreibung</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>?businessbriefe</code><br>
<code>?business-postkarten</code><br>
<code>?follow-ups</code><br>
<code>?einladungen</code><br>
<code>?private-briefe</code></td>
<td></td>
<td>Produkt direkt vorauswählen. Der Produktauswahlschritt wird übersprungen.</td>
</tr>
<tr>
<td><code>quantity</code></td>
<td>Zahl (z.B. <code>100</code>)</td>
<td>Menge vorausfüllen.</td>
</tr>
<tr>
<td><code>format</code></td>
<td><code>a4</code>, <code>a6h</code>, <code>a6q</code></td>
<td>Format vorauswählen. <code>a6h</code> = A6 Hochformat, <code>a6q</code> = A6 Querformat.</td>
</tr>
<tr>
<td><code>noPrice</code></td>
<td></td>
<td>Preise im Konfigurator ausblenden.</td>
</tr>
<tr>
<td><code>noLimits</code></td>
<td></td>
<td>Keine Mindestmengen. Erlaubt Bestellungen ab 1 Stück.</td>
</tr>
</tbody>
</table>
<h3 style="margin-top: 25px;">Beispiele</h3>
<ul style="margin-left: 20px;">
<li><code>/konfigurator/?businessbriefe</code> Direkt zu Business Briefe</li>
<li><code>/konfigurator/?einladungen&quantity=25&format=a6h</code> Einladungen mit 25 Stück im A6 Hochformat</li>
<li><code>/konfigurator/?businessbriefe&noPrice</code> Business Briefe ohne Preisanzeige</li>
<li><code>/konfigurator/?private-briefe&noLimits</code> Private Briefe ohne Mindestmenge</li>
<li><code>/konfigurator/?business-postkarten&noLimits&noPrice</code> Postkarten ohne Mindestmenge und ohne Preise</li>
</ul>
</div>
</div>
<?php
}
private function render_product_card(string $key, string $default_label, array $settings): void {
$label = $settings['products'][$key]['label'] ?? $default_label;
$description = $settings['products'][$key]['description'] ?? 'Professionelle handgeschriebene Korrespondenz';
$base_price = $settings['products'][$key]['base_price'] ?? '2.50';
?>
<div class="sk-product-card">
<h3><?php echo esc_html($default_label); ?></h3>
<div class="sk-field-row">
<label>Produktname</label>
<input type="text"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[products][<?php echo esc_attr($key); ?>][label]"
value="<?php echo esc_attr($label); ?>"
placeholder="<?php echo esc_attr($default_label); ?>">
</div>
<div class="sk-field-row">
<label>Beschreibung</label>
<textarea
name="<?php echo esc_attr(self::OPTION_KEY); ?>[products][<?php echo esc_attr($key); ?>][description]"
rows="3"
placeholder="Professionelle handgeschriebene Korrespondenz"><?php echo esc_textarea($description); ?></textarea>
</div>
<div class="sk-field-row">
<label>Startpreis (ab)</label>
<div>
<input type="number" step="0.01" min="0"
name="<?php echo esc_attr(self::OPTION_KEY); ?>[products][<?php echo esc_attr($key); ?>][base_price]"
value="<?php echo esc_attr($base_price); ?>"> €
</div>
</div>
</div>
<?php
}
public static function get_settings(): array {
$defaults = [
'products' => [
'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();

View File

@@ -0,0 +1,403 @@
<?php
/**
* Gutschein-Verwaltung für Skrift Konfigurator
*/
if (!defined('ABSPATH')) { exit; }
final class Skrift_Konfigurator_Vouchers {
const OPTION_KEY = 'skrift_konfigurator_vouchers';
public function __construct() {
add_action('admin_menu', [$this, 'add_menu_page']);
add_action('admin_init', [$this, 'register_settings']);
add_action('admin_post_sk_add_voucher', [$this, 'handle_add_voucher']);
add_action('admin_post_sk_delete_voucher', [$this, 'handle_delete_voucher']);
add_action('rest_api_init', [$this, 'register_rest_routes']);
}
public function add_menu_page(): void {
add_submenu_page(
'options-general.php',
'Skrift Gutscheine',
'Skrift Gutscheine',
'manage_options',
'skrift-vouchers',
[$this, 'render_vouchers_page']
);
}
public function register_settings(): void {
register_setting('skrift_vouchers', self::OPTION_KEY, [
'type' => '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, []);
?>
<div class="wrap">
<h1>Gutschein-Verwaltung</h1>
<?php if (isset($_GET['success'])): ?>
<div class="notice notice-success is-dismissible">
<p>
<?php
if ($_GET['success'] === 'added') {
echo 'Gutschein erfolgreich hinzugefügt!';
} elseif ($_GET['success'] === 'deleted') {
echo 'Gutschein erfolgreich gelöscht!';
}
?>
</p>
</div>
<?php endif; ?>
<?php if (isset($_GET['error'])): ?>
<div class="notice notice-error is-dismissible">
<p>
<?php
if ($_GET['error'] === 'empty_code') {
echo 'Gutscheincode darf nicht leer sein!';
} elseif ($_GET['error'] === 'duplicate') {
echo 'Dieser Gutscheincode existiert bereits!';
}
?>
</p>
</div>
<?php endif; ?>
<div style="background: white; padding: 20px; margin-top: 20px; border: 1px solid #ccc;">
<h2>Neuen Gutschein erstellen</h2>
<form method="post" action="<?php echo admin_url('admin-post.php'); ?>">
<input type="hidden" name="action" value="sk_add_voucher">
<?php wp_nonce_field('sk_add_voucher'); ?>
<table class="form-table">
<tr>
<th scope="row"><label for="voucher_code">Gutscheincode *</label></th>
<td>
<input type="text" id="voucher_code" name="voucher_code" class="regular-text" required
style="text-transform: uppercase;" placeholder="z.B. SOMMER2025">
<p class="description">Code wird automatisch in Großbuchstaben umgewandelt</p>
</td>
</tr>
<tr>
<th scope="row"><label for="voucher_type">Rabatt-Art *</label></th>
<td>
<select id="voucher_type" name="voucher_type" required>
<option value="percent">Prozent (%)</option>
<option value="fixed">Festbetrag (€)</option>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="voucher_value">Rabatt-Wert *</label></th>
<td>
<input type="number" id="voucher_value" name="voucher_value" step="0.01" min="0" required
class="small-text">
<p class="description">Bei Prozent: z.B. 10 für 10% | Bei Festbetrag: z.B. 5.00 für 5€</p>
</td>
</tr>
<tr>
<th scope="row"><label for="voucher_expiry">Ablaufdatum</label></th>
<td>
<input type="date" id="voucher_expiry" name="voucher_expiry" class="regular-text">
<p class="description">Leer lassen für unbegrenzte Gültigkeit</p>
</td>
</tr>
<tr>
<th scope="row"><label for="voucher_limit">Einlöse-Limit</label></th>
<td>
<input type="number" id="voucher_limit" name="voucher_limit" min="0" class="small-text" value="0">
<p class="description">0 = Unbegrenzt oft einlösbar</p>
</td>
</tr>
</table>
<?php submit_button('Gutschein erstellen'); ?>
</form>
</div>
<div style="background: white; padding: 20px; margin-top: 20px; border: 1px solid #ccc;">
<h2>Vorhandene Gutscheine</h2>
<?php if (empty($vouchers)): ?>
<p>Noch keine Gutscheine vorhanden.</p>
<?php else: ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th>Code</th>
<th>Typ</th>
<th>Wert</th>
<th>Ablaufdatum</th>
<th>Limit</th>
<th>Eingelöst</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($vouchers as $code => $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;
?>
<tr>
<td><strong><?php echo esc_html($voucher['code']); ?></strong></td>
<td><?php echo $voucher['type'] === 'percent' ? 'Prozent' : 'Festbetrag'; ?></td>
<td>
<?php
if ($voucher['type'] === 'percent') {
echo number_format($voucher['value'], 1) . ' %';
} else {
echo number_format($voucher['value'], 2, ',', '.') . ' €';
}
?>
</td>
<td>
<?php
if (!empty($voucher['expiry_date'])) {
echo date('d.m.Y', strtotime($voucher['expiry_date']));
} else {
echo '—';
}
?>
</td>
<td><?php echo $voucher['usage_limit'] > 0 ? $voucher['usage_limit'] : 'Unbegrenzt'; ?></td>
<td><?php echo $voucher['usage_count']; ?></td>
<td>
<?php if ($is_active): ?>
<span style="color: green; font-weight: bold;">✓ Aktiv</span>
<?php elseif ($is_expired): ?>
<span style="color: red;">✗ Abgelaufen</span>
<?php elseif ($is_used_up): ?>
<span style="color: orange;">✗ Limit erreicht</span>
<?php endif; ?>
</td>
<td>
<a href="<?php echo wp_nonce_url(
admin_url('admin-post.php?action=sk_delete_voucher&code=' . urlencode($code)),
'sk_delete_voucher'
); ?>"
class="button button-small"
onclick="return confirm('Gutschein <?php echo esc_js($code); ?> wirklich löschen?');">
Löschen
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
<?php
}
/**
* Öffentliche Funktion um Gutscheine abzurufen
*/
public static function get_vouchers() {
return get_option(self::OPTION_KEY, []);
}
/**
* Validiert einen Gutschein
*/
public static function validate_voucher($code) {
$vouchers = self::get_vouchers();
$code = strtoupper(trim($code));
if (!isset($vouchers[$code])) {
return ['valid' => 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();

View File

@@ -0,0 +1,351 @@
<?php
/**
* API Proxy für Skrift Konfigurator
* Leitet Anfragen an das Backend weiter, ohne den API-Token im Frontend zu exponieren
*/
if (!defined('ABSPATH')) { exit; }
final class Skrift_Konfigurator_API_Proxy {
public function __construct() {
add_action('rest_api_init', [$this, 'register_rest_routes']);
}
/**
* REST API Routen registrieren
*/
public function register_rest_routes() {
// Health Check
register_rest_route('skrift/v1', '/proxy/health', [
'methods' => '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<sessionId>[a-zA-Z0-9_-]+)/(?P<index>\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'], '<svg') !== false) {
$response = new WP_REST_Response($result['data']);
$response->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, '<svg') !== false) {
$real_type = 'image/svg+xml';
}
}
if (!in_array($real_type, $allowed_types) && !in_array($file['type'], $allowed_types)) {
return new WP_Error('invalid_type', 'Ungültiger Dateityp: ' . $real_type, ['status' => 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();

View File

View File

@@ -0,0 +1,200 @@
<?php
/**
* Plugin Name: Skrift Konfigurator
* Description: Interaktiver Konfigurator für handgeschriebene Briefe
* Version: 0.3.0
* Author: Skrift
*/
if (!defined('ABSPATH')) { exit; }
// Admin Settings IMMER laden (für REST API Permission Callbacks)
require_once plugin_dir_path(__FILE__) . 'includes/admin-settings.php';
// Gutscheine IMMER laden (für REST API)
require_once plugin_dir_path(__FILE__) . 'includes/admin-vouchers.php';
// Bestellnummern-Verwaltung laden (für REST API)
require_once plugin_dir_path(__FILE__) . 'includes/admin-orders.php';
// Backend API Proxy laden (für REST API)
require_once plugin_dir_path(__FILE__) . 'includes/api-proxy.php';
final class Skrift_Konfigurator_Plugin {
const VERSION = '0.3.0';
const SLUG = 'skrift-konfigurator';
public function __construct() {
add_action('wp_enqueue_scripts', [$this, 'register_assets']);
add_filter('script_loader_tag', [$this, 'add_module_attribute'], 10, 3);
add_shortcode('skrift_konfigurator', [$this, 'render_shortcode']);
add_shortcode('skrift_preisrechner', [$this, 'render_preisrechner_shortcode']);
}
public function register_assets(): void {
$base = plugin_dir_url(__FILE__);
wp_register_style(
self::SLUG,
$base . 'assets/css/configurator.css',
[],
self::VERSION
);
wp_register_script(
self::SLUG,
$base . 'assets/js/configurator-app.js',
[],
self::VERSION,
true
);
// Preisrechner Script
wp_register_script(
'skrift-preisrechner',
$base . 'assets/js/price-calculator.js',
[],
self::VERSION,
true
);
}
public function add_module_attribute(string $tag, string $handle, string $src): string {
// Beide Scripts als ES6-Module laden
if ($handle !== self::SLUG && $handle !== 'skrift-preisrechner') return $tag;
return '<script type="module" src="' . esc_url($src) . '"></script>';
}
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();
?>
<script>
<?php
// Settings kopieren aber sensible Daten entfernen
$frontend_settings = $settings;
// API Token NICHT ans Frontend senden - wird über WordPress Proxy gehandhabt
unset($frontend_settings['backend_connection']['api_token']);
// API Security Key NICHT ans Frontend senden
unset($frontend_settings['api_security']);
// PayPal Secrets NICHT ans Frontend senden
unset($frontend_settings['paypal']['client_secret_sandbox']);
unset($frontend_settings['paypal']['client_secret_live']);
?>
window.SkriftConfigurator = <?php echo wp_json_encode([
'version' => self::VERSION,
'restUrl' => esc_url_raw(rest_url()),
'nonce' => wp_create_nonce('wp_rest'),
'apiKey' => $settings['api_security']['api_key'] ?? '', // API Key für REST-Aufrufe
'settings' => $frontend_settings,
'vouchers' => $vouchers,
'paypal' => $paypal_frontend,
]); ?>;
</script>
<style>.sk-configurator{opacity:0;transition:opacity .2s ease}.sk-configurator.sk-ready{opacity:1}</style>
<div class="sk-configurator" data-skrift-konfigurator="1">
<div class="sk-configurator__layout">
<!-- Main Content Area -->
<div class="sk-main">
<!-- Top Bar mit Preis und Stepper -->
<div id="sk-topbar" class="sk-topbar"></div>
<!-- Stepper -->
<div id="sk-stepper"></div>
<!-- Form Content -->
<div id="sk-form"></div>
<!-- Mobile Preview (nur auf mobilen Geräten sichtbar, vor dem Button) -->
<div id="sk-preview-mobile" class="sk-preview-mobile"></div>
<!-- Navigation Buttons -->
<div class="sk-nav">
<button type="button" id="sk-prev" class="sk-btn sk-btn-secondary">
← Zurück
</button>
<button type="button" id="sk-next" class="sk-btn sk-btn-primary">
Weiter →
</button>
</div>
<!-- Mobile Contact Card (nach dem Button, nur mobile) -->
<div id="sk-contact-mobile" class="sk-contact-card-mobile"></div>
</div>
<!-- Preview Sidebar -->
<aside class="sk-side">
<div id="sk-preview"></div>
</aside>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Rendert den Preisrechner Shortcode
*/
public function render_preisrechner_shortcode($atts = []): string {
wp_enqueue_style(self::SLUG);
wp_enqueue_script('skrift-preisrechner');
// Einstellungen aus DB holen (gleiche wie Konfigurator)
$settings = Skrift_Konfigurator_Admin_Settings::get_settings();
ob_start();
?>
<script>
<?php
// Settings kopieren aber sensible Daten entfernen
$frontend_settings = $settings;
unset($frontend_settings['backend_connection']['api_token']);
unset($frontend_settings['api_security']);
unset($frontend_settings['paypal']);
?>
window.SkriftPreisrechner = <?php echo wp_json_encode([
'version' => self::VERSION,
'settings' => $frontend_settings,
]); ?>;
</script>
<div class="sk-configurator" data-skrift-preisrechner="1">
<!-- Preisrechner wird per JavaScript gerendert -->
</div>
<?php
return ob_get_clean();
}
}
new Skrift_Konfigurator_Plugin();