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

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