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

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