Initial commit

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

View File

@@ -0,0 +1,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();