dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxShopAdmin.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxShop_admin;
3
4use dbx\dbxContent\dbxContentLng;
5use dbx\dbxContent\dbxContentLngSync;
6use dbx\dbxContent\dbxContentMediaUsageScope;
7
8require_once dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap_sync.php';
9
11
12 private const ACTION_TOKEN_SCOPE = 'dbxShop_admin.actions';
13
18 private bool $maintenanceMode = false;
19
27 private string $postedFormError = '';
28 private $catalogTexts = null;
29
37 private function catalogTexts() {
38 if ($this->catalogTexts) {
39 return $this->catalogTexts;
40 }
41 dbx()->get_system_obj('dbxForm', 'use');
42 $texts = new \dbxForm();
43 $texts->init('shop-catalog-texts');
44 $texts->_fd = 'dbxShop_admin|shop-catalog';
45 $texts->load_fd_messages();
46 $texts->set_form_help_enabled(false);
47 $this->catalogTexts = $texts;
48 return $this->catalogTexts;
49 }
50
51 private function tpl() {
52 return dbx()->get_system_obj('dbxTPL');
53 }
54
55 private function db() {
56 return dbx()->get_system_obj('dbxDB');
57 }
58
59 private function h($value): string {
60 return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
61 }
62
69 private function actionUrl(string $url): string {
70 $securedUrl = dbx()->action_url($url);
71 if ($securedUrl !== $url) {
72 return $securedUrl;
73 }
74
75 $separator = strpos($url, '?') === false ? '?' : '&';
76 return $url . $separator . 'dbx_token=' . rawurlencode(dbx()->action_token(self::ACTION_TOKEN_SCOPE));
77 }
78
82 private function checkActionToken(string $action): bool {
83 $token = (string)dbx()->get_modul_var('dbx_token', '', 'varchar|max=128');
84 if (dbx()->check_action_token(self::ACTION_TOKEN_SCOPE, $token)) {
85 return true;
86 }
87
88 $this->postedFormError = $this->catalogTexts()->get_fd_message('security_token_error');
89 dbx()->sys_msg(
90 'security',
91 'dbxShop_admin',
92 $action,
93 'Shop-Admin-Aktion ohne gueltigen Token abgewiesen',
94 'ip=' . (string)($_SERVER['REMOTE_ADDR'] ?? '')
95 );
96 return false;
97 }
98
99 private function openWinButton(string $url, string $title, string $content, string $class = 'btn btn-outline-primary', string $width = '88%', string $height = '88%'): string {
100 $escUrl = $this->h($url);
101 $escTitle = $this->h($title);
102 $class = trim($class);
103 if (strpos(' ' . $class . ' ', ' openWin ') === false) {
104 $class .= ' openWin';
105 }
106 if (strpos(' ' . $class . ' ', ' dbx-win ') === false) {
107 $class .= ' dbx-win';
108 }
109 if (strpos($content, 'bi-question-circle') !== false && strpos(' ' . $class . ' ', ' dbx-help-action ') === false) {
110 $class .= ' dbx-help-action';
111 }
112 return '<a class="' . $this->h($class) . '" href="' . $escUrl . '" data-url="' . $escUrl . '" data-title="' . $escTitle . '" data-width="' . $this->h($width) . '" data-height="' . $this->h($height) . '" title="' . $escTitle . '" role="button">' . $content . '</a>';
113 }
114
115 private function helpButton(int $helpId, string $title, string $class = 'btn btn-outline-secondary btn-sm me-1', string $width = '72%', string $height = '82%'): string {
116 if ($helpId <= 0) {
117 return '';
118 }
119 return $this->openWinButton(
120 '?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId,
121 $title,
122 '<i class="bi bi-question-circle"></i><span class="visually-hidden"> Hilfe</span>',
123 $class,
124 $width,
125 $height
126 );
127 }
128
129 private function money($value): string {
130 return number_format((float) $value, 2, ',', '.') . ' EUR';
131 }
132
133 private function mediaUrl(string $path): string {
134 $path = trim(str_replace('\\', '/', $path));
135 if ($path === '') return '';
136 if (preg_match('~^https?://~i', $path) || substr($path, 0, 1) === '/') return $path;
137 return dbx()->get_base_url() . ltrim($path, '/');
138 }
139
140 private function mediaItemUrl(array $image, bool $thumb = true): string {
141 $mediaId = (int)($image['media_id'] ?? 0);
142 if ($mediaId > 0) {
143 $url = 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $mediaId;
144 if ($thumb) {
145 $url .= '&dbx_thumb=1';
146 }
147 return $url;
148 }
149 return $this->mediaUrl((string)($image['image_path'] ?? ''));
150 }
151
152 private function repo() {
153 return dbx()->get_include_obj('dbxShopRepository', 'dbxShop');
154 }
155
156 private function ensureSeed(): void {
157 // Normale Admin-Aufrufe duerfen weder Schema noch Demo-Daten aendern.
158 // Installation und Wartung erfolgen ausschliesslich ueber run1=install.
159 $this->repo()->install();
160 }
161
162 private function shopMediaUsageContentId(): int {
163 $configured = (int)dbx()->get_config('dbxShop', 'media_usage_content_id');
164 if ($configured > 0 && $this->contentPageExists($configured, $this->shopMediaUsageContentDd())) {
165 return $configured;
166 }
167
168 return $this->ensureShopMediaUsagePage();
169 }
170
171 private function contentDd(): string {
172 return function_exists('dbx_lng_name') ? dbx_lng_name('content') : 'content_de';
173 }
174
175 private function shopMediaUsageLng(): string {
176 return dbxContentMediaUsageScope::language(dbxContentLngSync::masterLng());
177 }
178
179 private function shopMediaUsageContentDd(): string {
180 return dbxContentLng::ddContent($this->shopMediaUsageLng());
181 }
182
183 private function shopMediaUsageFolderDd(): string {
184 return dbxContentLng::ddFolder($this->shopMediaUsageLng());
185 }
186
187 private function folderDd(): string {
188 return function_exists('dbx_lng_name') ? dbx_lng_name('content_folder') : 'content_folder_de';
189 }
190
191 private function contentPageExists(int $contentId, string $dd = ''): bool {
192 if ($contentId <= 0) {
193 return false;
194 }
195 try {
196 $row = $this->db()->select1($dd !== '' ? $dd : $this->contentDd(), $contentId, 'id', 0);
197 return is_array($row) && (int)($row['id'] ?? 0) === $contentId;
198 } catch (\Throwable $e) {
199 return false;
200 }
201 }
202
203 private function ensureShopHelpFolder(): int {
204 $db = $this->db();
205 $folderDd = $this->folderDd();
206 $row = $db->select1($folderDd, array('name' => 'shop', 'parent_id' => 0), 'id', 0);
207 if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
208 return (int)$row['id'];
209 }
210
211 $folderId = 0;
212 $folderOk = (int)$db->insert($folderDd, array(
213 'name' => 'shop',
214 'parent_id' => 0,
215 'sorter' => '9000',
216 'group_read' => 'admin',
217 'template' => 'c-content',
218 'hero_template' => 'image-hero',
219 'hero_image_id' => '',
220 'hero_margin_top' => '',
221 'hero_height' => '',
222 'hero_variant' => '',
223 'hero_sticky' => '',
224 'hero_scroll_layer' => '',
225 ));
226 if ($folderOk === 1) {
227 $folderId = (int)$db->get_insert_id();
228 }
229 if ($folderId > 0) {
230 return $folderId;
231 }
232
233 return 0;
234 }
235
236 private function channelGroupsHelpHtml(): string {
237 return '<section class="dbx-shop-help">'
238 . '<h2>Channel-Gruppen</h2>'
239 . '<p>Channel-Gruppen buendeln die Verkaufskanaele, auf denen ein Shop-Artikel angeboten werden soll. Sie werden spaeter Artikeln zugeordnet und steuern damit, ob ein Artikel nur im Shop, auf Marktplatzkanaelen oder in mehreren Kanaelen gefuehrt wird.</p>'
240 . '<h3>Eingabefelder</h3>'
241 . '<dl>'
242 . '<dt>Key</dt><dd>Eindeutiger technischer Schluessel der Channel-Gruppe. Verwenden Sie kurze, stabile Werte wie <code>software-shop</code> oder <code>merch-marketplaces</code>. Der Key wird beim Neuanlegen gespeichert und danach nicht mehr direkt in der Liste geaendert.</dd>'
243 . '<dt>Channel-Gruppe</dt><dd>Sichtbarer Name fuer die Verwaltung. Dieser Name sollte knapp beschreiben, fuer welche Artikel oder Verkaufssituation die Gruppe gedacht ist.</dd>'
244 . '<dt>Beschreibung</dt><dd>Interne Erklaerung der Gruppe. Tragen Sie ein, welche Artikel in diese Gruppe gehoeren und warum die ausgewaehlten Channels passen.</dd>'
245 . '<dt>Channels</dt><dd>Auswahl der aktiven Verkaufskanaele innerhalb dieser Gruppe. Aktivierte Checkboxen bedeuten: Artikel mit dieser Gruppe duerfen auf diesen Kanaelen gefuehrt oder exportiert werden.</dd>'
246 . '<dt>Sort</dt><dd>Reihenfolge in Auswahllisten und Tabellen. Kleine Zahlen stehen weiter oben. Nutzen Sie Abstaende wie 10, 20, 30, damit spaeter Gruppen dazwischen einsortiert werden koennen.</dd>'
247 . '<dt>Aktiv</dt><dd>Schaltet die Channel-Gruppe fuer die Nutzung ein oder aus. Inaktive Gruppen bleiben erhalten, werden aber nicht als aktive Option behandelt.</dd>'
248 . '</dl>'
249 . '<h3>Aktionen</h3>'
250 . '<ul>'
251 . '<li><strong>Plus in der Bar:</strong> fuegt oben eine leere Eingabezeile fuer eine neue Channel-Gruppe ein.</li>'
252 . '<li><strong>Speichern:</strong> uebernimmt Name, Beschreibung, Channel-Auswahl, Sortierung und Aktiv-Status.</li>'
253 . '<li><strong>Loeschen:</strong> verschiebt die Channel-Gruppe in den Papierkorb und deaktiviert ihre Channel-Zuordnungen. Bestehende Produktdaten bleiben dadurch nachvollziehbar, die Gruppe erscheint aber nicht mehr in der aktiven Liste.</li>'
254 . '</ul>'
255 . '<p><strong>Praxis:</strong> Legen Sie Gruppen nach Vertriebsszenarien an, nicht nach einzelnen Artikeln. Beispiele sind <code>software-shop</code>, <code>digital-marketplaces</code> oder <code>service-local</code>.</p>'
256 . '</section>';
257 }
258
259 private function channelsHelpHtml(): string {
260 return '<section class="dbx-shop-help">'
261 . '<h2>Channels</h2>'
262 . '<p>Channels beschreiben einzelne Verkaufskanaele wie Shop, eBay, Amazon, Kleinanzeigen oder eigene Marktplatz-Anbindungen. Ein aktiver Channel kann fuer Artikel-Export, Listing-Pflege und spaeter fuer Bestellrueckmeldungen genutzt werden.</p>'
263 . '<h3>Eingabefelder</h3>'
264 . '<dl>'
265 . '<dt>Key</dt><dd>Eindeutiger technischer Schluessel des Channels, zum Beispiel <code>ebay</code> oder <code>amazon</code>. Der Key wird beim Neuanlegen vergeben und von Artikelzuordnungen und Bestellungen referenziert.</dd>'
266 . '<dt>Channel</dt><dd>Sichtbarer Name in Verwaltung, Artikelliste und Channel-Gruppen.</dd>'
267 . '<dt>Beschreibung</dt><dd>Interne Notiz, wofuer der Channel genutzt wird und welche Besonderheiten bei Angeboten gelten.</dd>'
268 . '<dt>Plattform</dt><dd>Typ der Zielplattform. Vordefinierte Werte helfen spaeter beim Anschluss spezifischer API-Adapter; <code>custom</code> ist fuer eigene Integrationen gedacht.</dd>'
269 . '<dt>Verbindung</dt><dd>Legt fest, ob der Channel intern, manuell, per API oder per Feed/Webhook betrieben wird.</dd>'
270 . '<dt>Export</dt><dd>Erlaubt, Artikel aus dem Shop fuer diesen Channel vorzubereiten oder spaeter automatisiert als Listing zu uebertragen.</dd>'
271 . '<dt>Order-Import</dt><dd>Erlaubt Bestellrueckmeldungen vom Channel in den Shop. Externe Plattformen benoetigen dafuer API-Zugriff oder einen Webhook.</dd>'
272 . '<dt>API-Basis-URL</dt><dd>Basisadresse der Channel-API. Der Verbindungstest nutzt je nach Plattform den passenden Connector, z.B. eBay OAuth/Sell API, Amazon LWA/SP-API oder mobile.de Basic Auth.</dd>'
273 . '<dt>Client-ID / Benutzer</dt><dd>Oeffentliche Kennung oder Login-Name der Integration.</dd>'
274 . '<dt>Secret / Token / Passwort</dt><dd>Zugangsdaten fuer API-Aufrufe. Gespeicherte Werte werden im Feld angezeigt und koennen direkt ueberschrieben werden.</dd>'
275 . '<dt>Marketplace-ID</dt><dd>Marktplatzkennung der Plattform, zum Beispiel <code>EBAY_DE</code> fuer eBay Deutschland oder <code>A1PA6795UKMFR9</code> fuer Amazon.de.</dd>'
276 . '<dt>Seller-ID / Account-ID</dt><dd>Verkaeufer- oder Konto-ID der Plattform. Bei mobile.de ist hier insbesondere die <code>mobileSellerId</code> relevant.</dd>'
277 . '<dt>Location-Key</dt><dd>Lager-, Standort- oder Versandortkennung. eBay benoetigt einen Merchant Location Key, bevor Angebote veroeffentlicht werden koennen.</dd>'
278 . '<dt>Kategorie-ID</dt><dd>Standard-Kategorie fuer neue Listings, wenn der Adapter keine automatische Kategorieermittlung nutzt.</dd>'
279 . '<dt>Payment-, Fulfillment- und Return-Policy</dt><dd>Policy-IDs der Plattform. Bei eBay sind das die Business Policies des Verkaeuferkontos: <strong>Payment Policy</strong> beschreibt Zahlungsarten und Zahlungsbedingungen, <strong>Fulfillment Policy</strong> beschreibt Versandservice, Bearbeitungszeit, Versandkosten und Versandregionen, <strong>Return Policy</strong> beschreibt Ruecknahmefrist, Kostentraeger und Rueckgabebedingungen. eBay-Angebote koennen ohne passende Policy-IDs oft nicht veroeffentlicht werden.</dd>'
280 . '<dt>Notification-Ziel / Topic</dt><dd>Ziel fuer Rueckmeldungen, zum Beispiel eBay Notification Destination oder Amazon SQS ARN, plus Ereignis wie <code>ORDER_CHANGE</code>.</dd>'
281 . '<dt>API-Scopes / Rollen</dt><dd>Dokumentation der erforderlichen Berechtigungen, etwa eBay OAuth Scopes oder Amazon SP-API Rollen. In der Maske am besten einen Wert pro Zeile eintragen. Wenn ein Anbieter technisch Leerzeichen erwartet, verbindet der spaetere Adapter diese Zeilen beim API-Aufruf passend.</dd>'
282 . '<dt>Webhook-Secret</dt><dd>Geheimer Wert zur Pruefung eingehender Rueckmeldungen, zum Beispiel wenn ein Kauf auf eBay oder Amazon im Shop als Bestellung angelegt werden soll.</dd>'
283 . '<dt>Sort / Aktiv</dt><dd>Sortierung und grundsaetzliche Freigabe des Channels.</dd>'
284 . '</dl>'
285 . '<h3>Aktionen</h3>'
286 . '<ul>'
287 . '<li><strong>Plus in der Bar:</strong> oeffnet ein Formular fuer einen neuen Channel.</li>'
288 . '<li><strong>Speichern:</strong> uebernimmt Stammdaten, Integrationsmodus und Zugangsdaten.</li>'
289 . '<li><strong>Test:</strong> prueft provider-spezifisch die Pflichtfelder und, soweit Zugangsdaten vorhanden sind, einen echten API-Zugriff ohne Daten zu veraendern.</li>'
290 . '<li><strong>Loeschen:</strong> deaktiviert und verschiebt den Channel in den Papierkorb. Artikelzuordnungen werden deaktiviert.</li>'
291 . '</ul>'
292 . '<p><strong>Praxis:</strong> Erst Channel konfigurieren, dann in Channel-Gruppen auswaehlen und anschliessend Artikel diesen Gruppen oder einzelnen Channels zuordnen.</p>'
293 . '</section>';
294 }
295
296 private function channelProviderHelpHtml(string $platform): string {
297 $common = '<h3>Gemeinsame Eingabefelder</h3>'
298 . '<dl>'
299 . '<dt>Key</dt><dd>Technischer Schluessel des Channels. Er wird in Artikelzuordnungen, Channel-Gruppen, Webhook-URLs und Bestellungen gespeichert. Nach dem Anlegen nicht mehr aendern.</dd>'
300 . '<dt>Channel</dt><dd>Sichtbarer Name in der Verwaltung.</dd>'
301 . '<dt>Plattform</dt><dd>Legt fest, welcher Adapter oder welche Arbeitsweise spaeter verwendet wird.</dd>'
302 . '<dt>Verbindung</dt><dd><code>Intern</code> fuer den Shop selbst, <code>Manuell</code> fuer Pflege ohne API, <code>API</code> fuer direkte Schnittstellen, <code>Feed</code> fuer Dateiuebergaben und <code>Webhook</code> fuer Rueckmeldungen.</dd>'
303 . '<dt>Aktiv</dt><dd>Nur aktive Channels koennen Artikeln zugeordnet und fuer Importe verwendet werden.</dd>'
304 . '<dt>Export</dt><dd>Erlaubt, Artikel fuer diesen Channel vorzubereiten oder spaeter automatisiert zur Plattform zu uebertragen.</dd>'
305 . '<dt>Order-Import</dt><dd>Erlaubt, Bestellungen oder Kauf-/Lead-Rueckmeldungen vom Channel in dbxShop zu uebernehmen.</dd>'
306 . '<dt>API-Basis-URL</dt><dd>Basisadresse der Anbieter-API. Der Verbindungstest nutzt den passenden Connector und macht nur lesende Pruefungen.</dd>'
307 . '<dt>Client-ID</dt><dd>Oeffentliche App- oder Client-Kennung der Plattform.</dd>'
308 . '<dt>Client-Secret</dt><dd>Geheimer App-Schluessel. Der gespeicherte Wert wird angezeigt und kann direkt ersetzt werden.</dd>'
309 . '<dt>Access-Token</dt><dd>Kurzlebiger Zugriffstoken. Wird oft automatisch aus dem Refresh-Token erneuert.</dd>'
310 . '<dt>Refresh-Token</dt><dd>Langfristiger Token, mit dem neue Access-Tokens erzeugt werden.</dd>'
311 . '<dt>Benutzer / Passwort</dt><dd>Nur fuer Plattformen mit Basic Auth oder klassischem API-Login verwenden.</dd>'
312 . '<dt>Marketplace-ID</dt><dd>Marktplatz- oder Laenderkennung, zum Beispiel <code>EBAY_DE</code> oder <code>A1PA6795UKMFR9</code>.</dd>'
313 . '<dt>Seller-ID</dt><dd>Verkaeuferkennung der Plattform.</dd>'
314 . '<dt>Account-ID / mobileSellerId</dt><dd>Konto-, Haendler- oder mobile.de Seller-ID.</dd>'
315 . '<dt>Location-Key</dt><dd>Standort- oder Lagerkennung, relevant fuer eBay-Angebote.</dd>'
316 . '<dt>Kategorie-ID</dt><dd>Plattformkategorie fuer Listings, falls keine automatische Zuordnung genutzt wird.</dd>'
317 . '<dt>Payment-, Fulfillment-, Return-Policy</dt><dd>Policy-IDs fuer Zahlungs-, Versand- und Rueckgaberegeln. Fuer eBay sind diese Felder besonders wichtig; Details stehen im eBay-Hilfetext.</dd>'
318 . '<dt>Notification-Ziel / SQS ARN</dt><dd>Zieladresse fuer Ereignisse: HTTPS-Destination bei eBay oder Amazon SQS ARN bei Amazon.</dd>'
319 . '<dt>Notification-Topic</dt><dd>Ereignistyp, der abonniert wird, zum Beispiel <code>ORDER_CHANGE</code>.</dd>'
320 . '<dt>API-Scopes / Rollen</dt><dd>Dokumentation der genehmigten Berechtigungen. Empfehlung fuer die Eingabe: ein Scope oder eine Rolle pro Zeile. eBay verlangt die Scopes im OAuth-Request technisch als leerzeichengetrennte Liste; beim Senden wird das dann URL-encodiert. Amazon verwendet hier eher Rollen/Berechtigungen, keine eBay-artige Scope-Liste.</dd>'
321 . '<dt>Webhook-Secret</dt><dd>Geheimer Wert, mit dem dbxShop eingehende Channel-Webhooks pruefen kann.</dd>'
322 . '<dt>dbxShop Webhook-URL</dt><dd>URL, die eine Plattform oder Middleware fuer Rueckmeldungen aufrufen kann.</dd>'
323 . '</dl>';
324
325 $linksTitle = '<h3>Beantragen / Bearbeiten</h3>';
326 switch ($platform) {
327 case 'ebay':
328 return '<section class="dbx-shop-help">'
329 . '<h2>Channel-Hilfe: eBay</h2>'
330 . '<p>eBay ist ein API-faehiger Marktplatz-Channel. Fuer automatische Listings braucht dbxShop OAuth-Zugangsdaten, Marketplace, Location und Business-Policies. Fuer Bestellungen werden Fulfillment API oder Notification API genutzt.</p>'
331 . $common
332 . '<h3>eBay-spezifisch</h3><ol>'
333 . '<li>Developer-Konto anlegen und Application Keyset erstellen. Daraus kommen Client-ID und Client-Secret.</li>'
334 . '<li>OAuth User Consent fuer den eBay-Verkaeufer durchlaufen. Daraus kommt der Refresh-Token.</li>'
335 . '<li>Scopes fuer Inventory, Fulfillment und Notifications eintragen.</li>'
336 . '<li>Marketplace-ID setzen, fuer Deutschland meist <code>EBAY_DE</code>.</li>'
337 . '<li>Merchant Location Key anlegen oder aus der Inventory API uebernehmen.</li>'
338 . '<li>Payment-, Fulfillment- und Return-Policy in eBay Business Policies pflegen. Die IDs kommen aus dem eBay Seller-Konto oder aus der Account API. Ohne diese IDs kann ein Offer zwar vorbereitet sein, aber haeufig nicht erfolgreich published werden.</li>'
339 . '<li><strong>Payment Policy ID:</strong> ID der Zahlungsregel, z.B. welche Zahlungsarten erlaubt sind und wann bezahlt werden muss.</li>'
340 . '<li><strong>Fulfillment Policy ID:</strong> ID der Versandregel, z.B. Versanddienst, Bearbeitungszeit, Kosten, kostenlose Lieferung, internationale Versandzonen.</li>'
341 . '<li><strong>Return Policy ID:</strong> ID der Rueckgaberegel, z.B. ob Rueckgabe akzeptiert wird, Frist und wer Ruecksendekosten traegt.</li>'
342 . '<li>Notification Destination auf die dbxShop Webhook-URL setzen und Topic/Subscription fuer Order-Ereignisse konfigurieren.</li>'
343 . '</ol>'
344 . '<h3>eBay Scopes richtig eintragen</h3>'
345 . '<p>eBay OAuth erwartet Scopes technisch im Parameter <code>scope</code> als durch Leerzeichen getrennte Liste. In diesem Formular ist es uebersichtlicher, jeden Scope in eine eigene Zeile zu schreiben. Ein spaeterer eBay-Adapter kann daraus automatisch die leerzeichengetrennte und URL-encodierte OAuth-Liste bauen.</p>'
346 . '<p>Beispiel:</p>'
347 . '<pre><code>https://api.ebay.com/oauth/api_scope/sell.inventory&#10;https://api.ebay.com/oauth/api_scope/sell.fulfillment&#10;https://api.ebay.com/oauth/api_scope/commerce.notification.subscription</code></pre>'
348 . $linksTitle
349 . '<ul>'
350 . '<li><a href="https://developer.ebay.com/develop/guides-v2/get-started-with-ebay-apis" target="_blank" rel="noopener">eBay APIs starten und Application Keys</a></li>'
351 . '<li><a href="https://developer.ebay.com/develop/guides-v2/authorization" target="_blank" rel="noopener">eBay OAuth und Scopes</a></li>'
352 . '<li><a href="https://developer.ebay.com/api-docs/sell/inventory/overview.html" target="_blank" rel="noopener">eBay Inventory API</a></li>'
353 . '<li><a href="https://developer.ebay.com/api-docs/sell/fulfillment/overview.html" target="_blank" rel="noopener">eBay Fulfillment API / Orders</a></li>'
354 . '<li><a href="https://developer.ebay.com/api-docs/commerce/notification/overview.html" target="_blank" rel="noopener">eBay Notification API</a></li>'
355 . '<li><a href="https://www.ebay.com/help/selling/business-policies/business-policies?id=4212" target="_blank" rel="noopener">eBay Business Policies</a></li>'
356 . '</ul></section>';
357
358 case 'amazon':
359 return '<section class="dbx-shop-help">'
360 . '<h2>Channel-Hilfe: Amazon</h2>'
361 . '<p>Amazon nutzt die Selling Partner API. Fuer Listings und Orders braucht dbxShop eine registrierte SP-API App, LWA-Zugangsdaten, Seller-Autorisierung, Seller-ID, Marketplace-ID und passende Rollen.</p>'
362 . $common
363 . '<h3>Amazon-spezifisch</h3><ol>'
364 . '<li>Professionelles Seller-Central-Konto und Entwicklerzugang vorbereiten.</li>'
365 . '<li>SP-API Anwendung im Solution Provider Portal registrieren.</li>'
366 . '<li>LWA Client-ID und Client-Secret aus der App-Registrierung uebernehmen.</li>'
367 . '<li>Seller-Autorisierung durchlaufen und Refresh-Token speichern.</li>'
368 . '<li>Marketplace-ID setzen, fuer Amazon.de <code>A1PA6795UKMFR9</code>.</li>'
369 . '<li>Seller-ID aus Seller Central oder aus der Autorisierung uebernehmen.</li>'
370 . '<li>Rollen fuer Product Listing, Orders und Notifications beantragen und im Feld API-Scopes/Rollen dokumentieren.</li>'
371 . '<li>Fuer Rueckmeldungen Amazon Notifications API mit SQS Destination einrichten; die SQS ARN im Notification-Ziel speichern.</li>'
372 . '</ol>'
373 . '<h3>Amazon-Felder verstaendlich erklaert</h3><dl>'
374 . '<dt>Client-ID und Client-Secret</dt><dd>Das sind die LWA-Zugangsdaten der registrierten SP-API Anwendung. Sie identifizieren die App, nicht den einzelnen Artikel.</dd>'
375 . '<dt>Refresh-Token</dt><dd>Dieser Token entsteht, wenn der Seller die App autorisiert. Er ist der wichtigste gespeicherte Wert, weil daraus neue Access-Tokens erzeugt werden.</dd>'
376 . '<dt>Marketplace-ID</dt><dd>Legt fest, auf welchem Amazon-Marktplatz gearbeitet wird. Fuer Deutschland ist das typischerweise <code>A1PA6795UKMFR9</code>.</dd>'
377 . '<dt>Seller-ID</dt><dd>Identifiziert das Seller-Konto, fuer das Listings und Orders verarbeitet werden.</dd>'
378 . '<dt>Kategorie-ID / Product Type</dt><dd>Amazon arbeitet nicht nur mit Kategorien, sondern stark mit Product Types und Pflichtattributen. Fuer Listings muss der Adapter wissen, welches Produktschema verwendet wird.</dd>'
379 . '<dt>Notification-Ziel / SQS ARN</dt><dd>Amazon sendet Rueckmeldungen nicht direkt an irgendeine URL, sondern typischerweise an eine AWS SQS Queue. Die ARN dieser Queue gehoert in dieses Feld.</dd>'
380 . '<dt>Notification-Topic</dt><dd>Fuer Bestellungen ist <code>ORDER_CHANGE</code> relevant. Damit koennen Statusaenderungen oder neue Bestellungen in dbxShop ankommen.</dd>'
381 . '<dt>API-Scopes / Rollen</dt><dd>Hier dokumentieren, welche SP-API Rollen genehmigt wurden, zum Beispiel Listings, Orders und Notifications. Das sind keine eBay-OAuth-Scopes. Tragen Sie am besten eine Rolle pro Zeile ein, damit klar bleibt, welche Berechtigung vorhanden ist.</dd>'
382 . '<dt>Payment/Fulfillment/Return Policies</dt><dd>Diese eBay-Felder sind fuer Amazon normalerweise nicht relevant. Amazon verwaltet Zahlungs- und Versandlogik anders ueber Seller Central, Fulfillment by Merchant oder FBA.</dd>'
383 . '</dl>'
384 . $linksTitle
385 . '<ul>'
386 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/registering-your-application" target="_blank" rel="noopener">Amazon SP-API App registrieren</a></li>'
387 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/authorization-overview" target="_blank" rel="noopener">Amazon SP-API Autorisierung</a></li>'
388 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/marketplace-ids" target="_blank" rel="noopener">Amazon Marketplace-IDs</a></li>'
389 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/listings-items-api" target="_blank" rel="noopener">Amazon Listings Items API</a></li>'
390 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/orders-api-v0-reference" target="_blank" rel="noopener">Amazon Orders API</a></li>'
391 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/notifications-api" target="_blank" rel="noopener">Amazon Notifications API</a></li>'
392 . '</ul></section>';
393
394 case 'mobile':
395 return '<section class="dbx-shop-help">'
396 . '<h2>Channel-Hilfe: mobile.de</h2>'
397 . '<p>mobile.de ist kein klassischer Warenkorb-Marktplatz, sondern vor allem ein Fahrzeuganzeigen- und Lead-Channel. Relevant sind Seller API fuer Anzeigen und Lead API fuer Rueckmeldungen.</p>'
398 . $common
399 . '<h3>mobile.de-spezifisch</h3><ol>'
400 . '<li>Dealer- oder API-Account bei mobile.de verwenden.</li>'
401 . '<li>API-Zugang im Dealer-Bereich oder ueber mobile.de Support aktivieren lassen.</li>'
402 . '<li>Benutzer und Passwort fuer Basic Auth eintragen.</li>'
403 . '<li>API-Basis-URL setzen, produktiv normalerweise <code>https://services.mobile.de</code> bzw. Seller-API-Pfad.</li>'
404 . '<li>Account-ID/mobileSellerId oder Customer Number aus Dealer Area/API-Daten uebernehmen.</li>'
405 . '<li>Fuer Rueckmeldungen Lead API nutzen; passende Lead-Events im Notification-Topic dokumentieren.</li>'
406 . '</ol>'
407 . '<h3>mobile.de-Felder verstaendlich erklaert</h3><dl>'
408 . '<dt>API-Basis-URL</dt><dd>Adresse der mobile.de Seller API, zum Beispiel <code>https://services.mobile.de/seller-api</code>. Sandbox und Produktion koennen unterschiedliche URLs haben.</dd>'
409 . '<dt>Benutzer / Passwort</dt><dd>mobile.de nutzt fuer viele API-Zugriffe Basic Auth. Deshalb sind API-Benutzer und API-Passwort wichtiger als Client-ID oder OAuth-Tokens.</dd>'
410 . '<dt>Account-ID / mobileSellerId</dt><dd>Die mobileSellerId identifiziert den Haendlerbestand. Ohne diese Zuordnung kann ein System oft nicht wissen, zu welchem Haendler ein Inserat gehoert.</dd>'
411 . '<dt>Seller-ID</dt><dd>Kann als Kundennummer oder interne Haendlerkennung dokumentiert werden.</dd>'
412 . '<dt>Kategorie-ID</dt><dd>Beschreibt die Art des Inserats, zum Beispiel Auto, Motorrad oder Nutzfahrzeug. mobile.de ist stark fahrzeugorientiert, nicht allgemeiner Warenkorb-Shop.</dd>'
413 . '<dt>Location-Key</dt><dd>Optionaler Standort oder Filialbezug, wenn ein Haendler mehrere Standorte hat.</dd>'
414 . '<dt>Notification-Ziel / Topic</dt><dd>Rueckmeldungen sind eher Leads, Anfragen oder Direct Offers. Dokumentieren Sie hier, welche Middleware oder Lead-API angebunden ist.</dd>'
415 . '<dt>Payment/Fulfillment/Return Policies</dt><dd>Diese eBay-Felder sind fuer mobile.de normalerweise nicht relevant, weil mobile.de keine klassischen Shop-Checkout-Regeln fuer diese Produkte verwaltet.</dd>'
416 . '<dt>API-Scopes / Rollen</dt><dd>mobile.de nutzt in der Regel Basic Auth und API-Freischaltungen statt OAuth-Scopes. Das Feld kann zur Dokumentation genutzt werden, zum Beispiel <code>seller-api</code> und <code>lead-api</code> jeweils in einer eigenen Zeile.</dd>'
417 . '</dl>'
418 . $linksTitle
419 . '<ul>'
420 . '<li><a href="https://services.mobile.de/" target="_blank" rel="noopener">mobile.de API Uebersicht und Accounts</a></li>'
421 . '<li><a href="https://services.mobile.de/manual/seller-api.html" target="_blank" rel="noopener">mobile.de Seller API</a></li>'
422 . '<li><a href="https://services.sandbox.mobile.de/manual/lead-api.html" target="_blank" rel="noopener">mobile.de Lead API Sandbox</a></li>'
423 . '<li><a href="https://services.mobile.de/manual/search-api.html" target="_blank" rel="noopener">mobile.de Search API / Customer IDs</a></li>'
424 . '</ul></section>';
425
426 case 'kleinanzeigen':
427 return '<section class="dbx-shop-help">'
428 . '<h2>Channel-Hilfe: Kleinanzeigen</h2>'
429 . '<p>Kleinanzeigen ist hier als manueller oder vertraglicher Channel vorbereitet. Eine allgemein frei nutzbare offizielle Anzeigen-/Order-API fuer normale Shop-Artikel ist nicht hinterlegt. Keine Scraper-Zugangsdaten als offizielle API behandeln.</p>'
430 . $common
431 . '<h3>Kleinanzeigen-spezifisch</h3><ol>'
432 . '<li>Wenn keine vertragliche Schnittstelle vorhanden ist, Verbindung auf <code>Manuell</code> lassen.</li>'
433 . '<li>Export nur aktivieren, wenn eine rechtlich freigegebene Middleware oder Schnittstelle vorhanden ist.</li>'
434 . '<li>API-Basis-URL, Benutzer, Token und Kategorie-ID nur aus dieser freigegebenen Schnittstelle uebernehmen.</li>'
435 . '<li>Order-Import nur aktivieren, wenn Kauf-/Kontakt-Rueckmeldungen offiziell oder ueber eine eigene Middleware geliefert werden.</li>'
436 . '<li>Fuer Immobilien gibt es offizielle Importschnittstellen; diese sind nicht automatisch eine allgemeine Artikel-API.</li>'
437 . '</ol>'
438 . '<h3>Kleinanzeigen-Felder verstaendlich erklaert</h3><dl>'
439 . '<dt>Verbindung</dt><dd>Ohne freigegebene Schnittstelle sollte dieser Channel auf <code>Manuell</code> stehen. Das bedeutet: dbxShop dokumentiert den Channel, uebertraegt aber nicht automatisch.</dd>'
440 . '<dt>API-Basis-URL / Client-ID / Secret / Token</dt><dd>Nur ausfuellen, wenn ein Vertrag, eine offizielle Importschnittstelle oder eine eigene Middleware diese Werte bereitstellt. Keine Login-Daten fuer Scraping eintragen.</dd>'
441 . '<dt>Kategorie-ID</dt><dd>Falls eine freigegebene Schnittstelle Kategorien verwendet, kann hier die Zielkategorie dokumentiert werden.</dd>'
442 . '<dt>Location-Key</dt><dd>Kann Ort, PLZ oder Standort-ID enthalten, wenn die Schnittstelle das erwartet.</dd>'
443 . '<dt>Notification-Ziel / Topic</dt><dd>Bei Kleinanzeigen sind Rueckmeldungen eher Nachrichten, Leads oder Kontaktanfragen. Ein echter Kauf-Webhook ist nur sinnvoll, wenn eine Middleware solche Ereignisse liefert.</dd>'
444 . '<dt>Order-Import</dt><dd>Nur aktivieren, wenn wirklich strukturierte Kauf-/Kontakt-Rueckmeldungen an dbxShop uebergeben werden koennen. Sonst bleibt die Pflege manuell.</dd>'
445 . '<dt>Payment/Fulfillment/Return Policies</dt><dd>Diese eBay-Felder sind fuer Kleinanzeigen normalerweise nicht relevant.</dd>'
446 . '<dt>API-Scopes / Rollen</dt><dd>Nur ausfuellen, wenn eine vertragliche Schnittstelle oder Middleware konkrete Berechtigungen nennt. Auch hier ist ein Wert pro Zeile am verstaendlichsten.</dd>'
447 . '</dl>'
448 . $linksTitle
449 . '<ul>'
450 . '<li><a href="https://hilfe-gewerblich.kleinanzeigen.de/artikel/schnittstellen" target="_blank" rel="noopener">Kleinanzeigen gewerbliche Schnittstellen</a></li>'
451 . '<li><a href="https://business.kleinanzeigen.de/" target="_blank" rel="noopener">Kleinanzeigen fuer Gewerbliche</a></li>'
452 . '</ul></section>';
453
454 case 'shop':
455 return '<section class="dbx-shop-help">'
456 . '<h2>Channel-Hilfe: Shop</h2>'
457 . '<p>Der Shop-Channel ist der interne Verkaufskanal. Er braucht keine externen API-Zugangsdaten.</p>'
458 . $common
459 . '<h3>Shop-spezifisch</h3><ol>'
460 . '<li>Verbindung auf <code>Intern</code> lassen.</li>'
461 . '<li>Export aktiv lassen, wenn Artikel im eigenen Shop sichtbar sein duerfen.</li>'
462 . '<li>Order-Import bleibt aus, weil Bestellungen direkt im Shop entstehen.</li>'
463 . '<li>API-Felder, Token, Marketplace-ID und Policies sind fuer den internen Shop nicht benoetigt.</li>'
464 . '</ol></section>';
465
466 default:
467 return '<section class="dbx-shop-help">'
468 . '<h2>Channel-Hilfe: Eigener Channel</h2>'
469 . '<p>Eigene Channels koennen an individuelle APIs, Feed-Exporte oder Middleware angebunden werden. Die konkreten Pflichtwerte haengen vom Adapter ab.</p>'
470 . $common
471 . '<h3>Vorgehen</h3><ol>'
472 . '<li>Beim Anbieter API-Zugang, Dokumentation und Testumgebung beantragen.</li>'
473 . '<li>API-Basis-URL, Authentifizierung und erforderliche IDs in die Felder uebernehmen.</li>'
474 . '<li>Webhook-Secret setzen und den dbxShop Webhook in der Middleware hinterlegen.</li>'
475 . '<li>Verbindung testen und erst danach Export oder Order-Import produktiv nutzen.</li>'
476 . '</ol></section>';
477 }
478 }
479
480 private function productGroupsHelpHtml(): string {
481 return '<section class="dbx-shop-help">'
482 . '<h2>Artikelgruppen</h2>'
483 . '<p>Artikelgruppen beschreiben fachliche Produktfamilien. Sie steuern Standardwerte wie Mehrwertsteuer, Darstellung, Detailansicht, Galerieverhalten und Hinweise zu Artikelattributen.</p>'
484 . '<h3>Eingabefelder</h3>'
485 . '<dl>'
486 . '<dt>Key</dt><dd>Eindeutiger technischer Schluessel der Artikelgruppe. Nutzen Sie kurze stabile Werte wie <code>software</code>, <code>merch</code> oder <code>service</code>. Der Key wird beim Neuanlegen vergeben und bleibt danach stabil.</dd>'
487 . '<dt>Obergruppe</dt><dd>Optionaler Platz in der Gruppenstruktur. Ohne Obergruppe erscheint die Gruppe als oberste Kachel im Katalog. Mit Obergruppe wird sie erst angezeigt, wenn der Kunde die uebergeordnete Gruppe anklickt, zum Beispiel <code>Kleidung / T-Shirts / male</code>.</dd>'
488 . '<dt>Gruppe</dt><dd>Sichtbarer Name der Artikelgruppe in Verwaltung und Auswahllisten.</dd>'
489 . '<dt>Beschreibung</dt><dd>Interne Beschreibung, welche Artikel in diese Gruppe gehoeren und wie sie im Shop verwendet wird.</dd>'
490 . '<dt>Gruppenbild</dt><dd>Bild fuer die Gruppenkachel im Katalog. Pro Artikelgruppe gibt es genau ein aktives Gruppenbild. Eine neue Auswahl ersetzt das bisherige Gruppenbild; die Datei im CMS-Medienbrowser bleibt erhalten.</dd>'
491 . '<dt>MwSt.</dt><dd>Standard-Mehrwertsteuersatz fuer Artikel dieser Gruppe. Einzelne Artikel koennen abweichen, wenn sie eigene Steuerwerte verwenden.</dd>'
492 . '<dt>Karte</dt><dd>Template fuer Produktkarten in Listen, Katalogen oder Teasern.</dd>'
493 . '<dt>Detail</dt><dd>Template fuer die Detailseite eines Artikels aus dieser Gruppe.</dd>'
494 . '<dt>Gallery</dt><dd>Template fuer die Bild- oder Mediengalerie der Gruppe.</dd>'
495 . '<dt>Bilder</dt><dd>Anzahl der initial sichtbaren Galeriebilder. Weitere Bilder koennen je nach Overflow-Einstellung sichtbar, scrollbar oder per Galerie erreichbar sein.</dd>'
496 . '<dt>Bildgroesse</dt><dd>Steuert, wie Bilder in der Galerie eingepasst werden: Original, Cover oder Contain.</dd>'
497 . '<dt>Lightbox</dt><dd>Breite der Lightbox-Ansicht, zum Beispiel <code>100vw</code> fuer volle Viewport-Breite.</dd>'
498 . '<dt>Overflow</dt><dd>Verhalten bei vielen Bildern, zum Beispiel Grid, Slider, Scroll, Laufband oder Tutorial.</dd>'
499 . '<dt>Klick</dt><dd>Definiert, was beim Klick auf ein Bild passiert: Lightbox, kein Klick, neuer Tab, ViewerJS oder PhotoSwipe.</dd>'
500 . '<dt>Artikelattribute</dt><dd>Interne Hinweise fuer Attribute dieser Gruppe, zum Beispiel welche technischen Daten, Varianten oder Filter erwartet werden.</dd>'
501 . '<dt>Channel-Vorgaben</dt><dd>Optionale Standardwerte fuer externe Verkaufskanaele. eBay Kategorie, Amazon Product Type, Kleinanzeigen Kategorie und mobile.de Kategorie werden bei Artikeln dieser Gruppe als Vorschlag im Channel-Mapping verwendet. Pro Artikel kann der Wert dort ueberschrieben werden. Wenn Channels in der Shop-Konfiguration deaktiviert sind, werden diese Eingaben ausgeblendet.</dd>'
502 . '<dt>Sort</dt><dd>Reihenfolge in Listen und Auswahlfeldern. Kleine Zahlen stehen weiter oben; Schritte von 10 lassen Platz fuer spaetere Gruppen.</dd>'
503 . '<dt>Aktiv</dt><dd>Schaltet die Artikelgruppe fuer die Verwendung ein oder aus. Inaktive Gruppen bleiben erhalten, werden aber nicht als aktive Option behandelt.</dd>'
504 . '</dl>'
505 . '<h3>Aktionen</h3>'
506 . '<ul>'
507 . '<li><strong>Plus in der Bar:</strong> legt oben eine neue Eingabezeile fuer eine Artikelgruppe an.</li>'
508 . '<li><strong>Speichern:</strong> uebernimmt alle sichtbaren Einstellungen der Zeile.</li>'
509 . '<li><strong>Loeschen:</strong> verschiebt die Artikelgruppe in den Papierkorb und deaktiviert sie. Bestehende Produktzuordnungen bleiben technisch nachvollziehbar, die Gruppe erscheint aber nicht mehr in der aktiven Liste.</li>'
510 . '</ul>'
511 . '<p><strong>Praxis:</strong> Verwenden Sie Artikelgruppen fuer wiederkehrende Produktlogik, nicht fuer einzelne Produkte. Beispiele sind Software, Merchandise, Service oder Zubehoer.</p>'
512 . '<p><strong>Katalog:</strong> Kunden sehen zunaechst die obersten Gruppenbilder. Ein Klick oeffnet die Untergruppen und die direkten Artikel dieser Gruppe. Ohne Gruppenauswahl zeigt der Katalog alle aktiven Artikel und kann weiterhin per Suche und Attributen gefiltert werden.</p>'
513 . '</section>';
514 }
515
516 private function shippingGroupsHelpHtml(): string {
517 return '<section class="dbx-shop-help">'
518 . '<h2>Versandgruppen</h2>'
519 . '<p>Versandgruppen definieren Versandweg, Lieferzeit, Standardkosten und Freigrenzen. Artikel koennen diese Werte aus der Gruppe uebernehmen oder eigene Versandwerte verwenden.</p>'
520 . '<h3>Eingabefelder</h3>'
521 . '<dl>'
522 . '<dt>Key</dt><dd>Eindeutiger technischer Schluessel der Versandgruppe. Beispiele sind <code>digital-free</code>, <code>service-remote</code> oder <code>merch-package</code>. Der Key wird beim Neuanlegen vergeben und bleibt danach stabil.</dd>'
523 . '<dt>Versandgruppe</dt><dd>Sichtbarer Name fuer Verwaltung und Auswahlfelder.</dd>'
524 . '<dt>Beschreibung</dt><dd>Interne Erklaerung, fuer welche Artikel und Liefersituationen diese Versandgruppe gedacht ist.</dd>'
525 . '<dt>Versandweg</dt><dd>Bezeichnung des Versand- oder Bereitstellungswegs, zum Beispiel Download, Remote-Termin, DHL Paket oder Spedition.</dd>'
526 . '<dt>Lieferzeit</dt><dd>Text fuer die erwartete Bereitstellung oder Lieferzeit, zum Beispiel <code>Sofort nach Freischaltung</code> oder <code>2-4 Werktage</code>.</dd>'
527 . '<dt>Kosten</dt><dd>Standard-Versandkosten brutto. Verwenden Sie <code>0</code> fuer kostenfreie Lieferung oder digitale Bereitstellung.</dd>'
528 . '<dt>Frei ab</dt><dd>Brutto-Warenwert, ab dem die Versandkosten entfallen. Der Wert <code>-1</code> bedeutet: keine Freigrenze.</dd>'
529 . '<dt>Sort</dt><dd>Reihenfolge in Listen und Auswahlfeldern. Kleine Zahlen stehen weiter oben.</dd>'
530 . '<dt>Aktiv</dt><dd>Schaltet die Versandgruppe fuer die Nutzung ein oder aus. Inaktive Gruppen bleiben erhalten, werden aber nicht als aktive Option behandelt.</dd>'
531 . '</dl>'
532 . '<h3>Aktionen</h3>'
533 . '<ul>'
534 . '<li><strong>Plus in der Bar:</strong> legt oben eine neue Eingabezeile fuer eine Versandgruppe an.</li>'
535 . '<li><strong>Speichern:</strong> uebernimmt Versandweg, Lieferzeit, Kosten, Freigrenze, Sortierung und Aktiv-Status.</li>'
536 . '<li><strong>Loeschen:</strong> verschiebt die Versandgruppe in den Papierkorb und deaktiviert sie. Bestehende Produktdaten bleiben nachvollziehbar, die Gruppe erscheint aber nicht mehr in der aktiven Liste.</li>'
537 . '</ul>'
538 . '<p><strong>Praxis:</strong> Trennen Sie digitale Bereitstellung, Remote-Leistungen und physische Paketlieferungen in eigene Versandgruppen.</p>'
539 . '</section>';
540 }
541
542 private function settingsHelpHtml(): string {
543 return '<section class="dbx-shop-help">'
544 . '<h2>Shop-Einstellungen</h2>'
545 . '<p>Diese Seite verwaltet globale dbxShop-Einstellungen. Artikel, Artikelgruppen, Versandgruppen und Channels nutzen diese Werte als gemeinsame Grundlage. Besonders wichtig ist die MwSt.-Logik: Artikel speichern keine eigenen Prozentwerte mehr, sondern Artikelgruppen waehlen eine MwSt.-Klasse wie <code>mwst1</code> oder <code>mwst2</code>. Der tatsaechliche Prozentwert wird hier gepflegt.</p>'
546 . '<h3>Shop</h3><dl>'
547 . '<dt>Shop aktiv</dt><dd>Schaltet den Shop grundsaetzlich ein oder aus. Wenn der Shop spaeter gesperrt werden soll, kann diese Einstellung fuer Frontend-Pruefungen genutzt werden.</dd>'
548 . '<dt>Standard-Channel</dt><dd>Technischer Standard-Verkaufskanal fuer normale Shop-Bestellungen. Fuer den eigenen Shop ist typischerweise <code>web</code> oder <code>shop</code> sinnvoll. Externe Plattformen wie eBay oder Amazon werden als eigene Channels gepflegt.</dd>'
549 . '<dt>Standard-Waehrung</dt><dd>ISO-Waehrungscode fuer Preise und Bestellungen, z.B. <code>EUR</code>. Der Wert sollte aus drei Buchstaben bestehen.</dd>'
550 . '<dt>Preisanzeige</dt><dd>Legt fest, ob Preise als Brutto- oder Netto-Anzeige interpretiert werden sollen. Fuer einen deutschen Verbraucher-Shop ist Brutto in der Regel die passende Anzeige. B2B-Szenarien koennen Netto verwenden, benoetigen aber klare Steuer- und Rechtstexte.</dd>'
551 . '<dt>B2B-Modus</dt><dd>Vorbereitung fuer Geschaeftskundenlogik. Diese Einstellung dokumentiert die Absicht, z.B. spaeter Netto-Preise, USt-ID-Pruefung oder andere Checkout-Regeln zu aktivieren.</dd>'
552 . '<dt>Lagerbestand nutzen</dt><dd>Aktiviert die Beruecksichtigung des Lagerbestands. Wenn aus, koennen Artikel unabhaengig vom Feld Lagerbestand angezeigt werden.</dd>'
553 . '<dt>Channels nutzen</dt><dd>Globaler Schalter fuer externe Verkaufskanaele wie eBay, Amazon, Kleinanzeigen oder mobile.de. Wenn deaktiviert, werden Channel-Funktionen im Shop-Admin ausgeblendet bzw. nicht verwendet. Welche externen Channels konkret aktiv sind, wird in der Channel-Verwaltung gepflegt und in den Einstellungen als Statusuebersicht angezeigt.</dd>'
554 . '</dl>'
555 . '<h3>MwSt.</h3><dl>'
556 . '<dt>Standard-MwSt.-Klasse</dt><dd>Standardklasse fuer neue Artikelgruppen oder Faelle ohne eindeutige Zuordnung. Ueblich ist <code>mwst1</code> fuer den regulaeren Satz.</dd>'
557 . '<dt>MwSt.-Anzeige</dt><dd>Legt fest, ob bei Preisen im Shop der Hinweis mit dem MwSt.-Satz angezeigt wird. Wenn deaktiviert, bleibt die steuerliche Berechnung intern erhalten, der sichtbare Preistext zeigt aber nur noch die Versandinformation. Fuer Sonderfaelle wie Kleinunternehmerregelung muessen passende Rechtstexte gepflegt werden.</dd>'
558 . '<dt>mwst1 - Name</dt><dd>Bezeichnung der ersten Steuerklasse, z.B. <code>MwSt. normal</code>. Diese Klasse wird normalerweise fuer den regulaeren deutschen Umsatzsteuersatz verwendet.</dd>'
559 . '<dt>mwst1 - Prozent</dt><dd>Prozentwert der ersten Steuerklasse, aktuell typischerweise <code>19</code>. Wenn sich der gesetzliche Satz aendert, wird dieser Wert hier zentral angepasst.</dd>'
560 . '<dt>mwst2 - Name</dt><dd>Bezeichnung der zweiten Steuerklasse, z.B. <code>MwSt. ermaessigt</code>. Diese Klasse ist fuer Artikel mit ermaessigtem Steuersatz gedacht.</dd>'
561 . '<dt>mwst2 - Prozent</dt><dd>Prozentwert der zweiten Steuerklasse, aktuell typischerweise <code>7</code>.</dd>'
562 . '<dt>mwst3 - Name</dt><dd>Dritte vorbereitete Steuerklasse. Sie kann fuer kuenftige Steuersaetze, Sonderfaelle oder Uebergangsregeln genutzt werden.</dd>'
563 . '<dt>mwst3 - Prozent</dt><dd>Prozentwert der dritten Steuerklasse, z.B. vorbereitet mit <code>22</code>. Solange Artikelgruppen diese Klasse nicht verwenden, wirkt sie sich nicht auf Artikelpreise aus.</dd>'
564 . '<dd><strong>Workflow:</strong> Prozentwerte hier pflegen, danach in <strong>Artikelgruppen</strong> nur die passende Klasse auswaehlen. Artikel erben den Steuersatz aus ihrer primaeren Artikelgruppe.</dd>'
565 . '</dl>'
566 . '<h3>Checkout und Rechtstexte</h3><dl>'
567 . '<dt>Gastbestellung erlauben</dt><dd>Erlaubt Bestellungen ohne vorheriges Kundenkonto. Wenn deaktiviert, verlangt der Checkout ein angemeldetes Benutzerkonto.</dd>'
568 . '<dt>Rechtstext-Snapshot speichern</dt><dd>Speichert bei Bestellungen den Stand von Rechtstexten und Widerrufsbelehrung als Snapshot. So bleibt nachvollziehbar, welche Texte zum Kaufzeitpunkt galten.</dd>'
569 . '<dt>Widerruf anzeigen</dt><dd>Steuert, ob der Widerrufsbereich im Shop sichtbar bzw. verwendbar sein soll. Der eigentliche Widerrufstext kommt aus der CMS-Seite <code>/shop-widerruf</code>.</dd>'
570 . '<dt>Kunden-Mail senden</dt><dd>Sendet nach Bestellung und Widerruf eine kurze Bestaetigung an die beim Checkout bzw. Widerruf angegebene E-Mail-Adresse.</dd>'
571 . '<dt>Admin-Mail senden</dt><dd>Sendet bei neuen Bestellungen und Widerrufen eine interne Benachrichtigung an die Admin-E-Mail-Adresse.</dd>'
572 . '<dt>Shop-Absender</dt><dd>Eigene From-Adresse fuer Bestellungen, Statusmeldungen und Widerrufe. Verwenden Sie eine echte Domain-Adresse, z.B. <code>shop@example.org</code>. Eine stabile Shop-Adresse erleichtert automatische Mailprozesse beim Empfaenger.</dd>'
573 . '<dt>Admin-E-Mail</dt><dd>Empfaengeradresse fuer interne Shop-Benachrichtigungen, z.B. <code>admin@example.org</code>.</dd>'
574 . '</dl>'
575 . '<h3>Zahlungsarten</h3><dl>'
576 . '<dt>Vorkasse aktiv</dt><dd>Aktiviert Vorkasse per Bankueberweisung. Die Bestellung wird gespeichert, die Zahlung bleibt offen, bis der Zahlungseingang im Admin bestaetigt wird.</dd>'
577 . '<dt>Kontoinhaber</dt><dd>Name des Zahlungsempfaengers fuer Vorkasse, z.B. Firmenname oder Shop-Betreiber.</dd>'
578 . '<dt>IBAN</dt><dd>Bankverbindung fuer Vorkasse. Dieser Wert wird Kunden nach der Bestellung und in der Bestellmail als Zahlungsinformation angezeigt.</dd>'
579 . '<dt>BIC</dt><dd>BIC/SWIFT der Bank. Innerhalb Deutschlands oft nicht zwingend, fuer internationale Kunden aber hilfreich.</dd>'
580 . '<dt>Bank</dt><dd>Name der Bank fuer die Vorkasse-Zahlungsinformation.</dd>'
581 . '<dt>Vorkasse-Hinweis</dt><dd>Text, der Kunden erklaert, wann und wie sie ueberweisen sollen. Die Bestellnummer wird zusaetzlich als Verwendungszweck angezeigt.</dd>'
582 . '<dt>Rechnung aktiv</dt><dd>Aktiviert Rechnung als Zahlungsart. Das ist meist nur fuer bestimmte Kunden oder B2B sinnvoll und sollte bei Bedarf mit Freigabelogik verbunden werden.</dd>'
583 . '<dt>Rechnungs-Hinweis</dt><dd>Text fuer Zahlungsziel oder interne Hinweise, z.B. <code>Bitte zahlen Sie innerhalb von 14 Tagen nach Rechnungserhalt.</code></dd>'
584 . '<dt>PayPal aktiv</dt><dd>Schaltet PayPal im Checkout frei. PayPal funktioniert erst, wenn Modus, Client-ID und Client-Secret korrekt eingetragen sind.</dd>'
585 . '<dt>PayPal Modus</dt><dd><code>Sandbox</code> nutzt die PayPal-Testumgebung. <code>Live</code> nutzt echte Zahlungen. Vor Livebetrieb immer zuerst Sandbox testen.</dd>'
586 . '<dt>PayPal Brand Name</dt><dd>Name, der bei PayPal im Zahlungsfenster erscheinen soll, z.B. der Shop- oder Firmenname.</dd>'
587 . '<dt>PayPal Client-ID</dt><dd>Oeffentliche App-Kennung aus dem PayPal Developer Dashboard. Fuer Sandbox und Live gibt es getrennte Zugangsdaten.</dd>'
588 . '<dt>PayPal Client-Secret</dt><dd>Geheimer App-Schluessel aus dem PayPal Developer Dashboard. Dieser Wert darf nicht oeffentlich angezeigt oder weitergegeben werden.</dd>'
589 . '<dd>PayPal-Zugangsdaten werden im PayPal Developer Dashboard unter <a href="https://developer.paypal.com/dashboard/applications/" target="_blank" rel="noopener">developer.paypal.com/dashboard/applications</a> erstellt und verwaltet. Sandbox-Testkonten und Testkaeufer finden Sie unter <a href="https://developer.paypal.com/tools/sandbox/" target="_blank" rel="noopener">developer.paypal.com/tools/sandbox</a>.</dd>'
590 . '<dt>Payment testen</dt><dd>Der Stecker-Button in der Bar prueft PayPal per OAuth-Token gegen Sandbox oder Live. Amazon Pay prueft vollstaendige Zugangsdaten und die lokale RSA-PSS-Signatur. Eine echte Amazon-Pay-Autorisierung wird beim Checkout mit einer echten Checkout Session ausgefuehrt.</dd>'
591 . '<dt>Amazon Pay aktiv</dt><dd>Schaltet Amazon Pay als auswaehlbare Zahlungsart frei. Beim Checkout wird eine Amazon-Pay-Checkout-Session erzeugt, der Kunde zu Amazon Pay weitergeleitet und die Zahlung nach Rueckkehr ueber Complete Checkout Session abgeschlossen.</dd>'
592 . '<dt>Amazon Pay Modus</dt><dd><code>Sandbox</code> fuer Tests, <code>Live</code> fuer echte Zahlungen. Die Zugangsdaten muessen zur jeweiligen Umgebung passen.</dd>'
593 . '<dt>Amazon Pay Region</dt><dd>Region des Amazon-Pay-Kontos. Fuer Deutschland typischerweise <code>EU</code>.</dd>'
594 . '<dt>Amazon Merchant-ID</dt><dd>Haendlerkennung aus der Amazon-Pay-/Seller-Central-Konfiguration.</dd>'
595 . '<dt>Amazon Store-ID</dt><dd>Store-/Checkout-Konfiguration aus Amazon Pay. Sie identifiziert, welche Store-Konfiguration fuer den Checkout genutzt werden soll.</dd>'
596 . '<dt>Public-Key-ID</dt><dd>Kennung des oeffentlichen Schluessels, der bei Amazon Pay hinterlegt wurde.</dd>'
597 . '<dt>Private Key</dt><dd>Privater Signaturschluessel fuer Amazon Pay. Dieser Wert ist geheim und darf nicht oeffentlich angezeigt werden.</dd>'
598 . '<dt>Sandbox Simulation</dt><dd>Optionaler Amazon-Pay-Simulationscode fuer Sandbox-Tests, z.B. <code>AmazonCanceled</code>. Leer lassen fuer normale Sandbox-Tests ohne erzwungene Fehlersimulation.</dd>'
599 . '<dd>Amazon-Pay-Zugangsdaten werden in Seller Central bzw. Amazon Pay Integration Central verwaltet: <a href="https://sellercentral.amazon.de/" target="_blank" rel="noopener">sellercentral.amazon.de</a>. Die technische API-v2-Dokumentation steht unter <a href="https://developer.amazon.com/docs/amazon-pay-api-v2/introduction.html" target="_blank" rel="noopener">developer.amazon.com/docs/amazon-pay-api-v2</a>.</dd>'
600 . '</dl>'
601 . '<h3>Channels</h3><dl>'
602 . '<dt>Aktive externe Channels</dt><dd>Zeigt alle aktiven Channels ausser dem internen <code>shop</code>-Channel. Aktiv, Export, Order-Import und Teststatus kommen aus der Channel-Verwaltung.</dd>'
603 . '<dt>Channels bearbeiten</dt><dd>Oeffnet die Channel-Verwaltung. Dort werden API-Zugangsdaten, Marketplace-, Seller-, Location- und Policy-Werte gepflegt.</dd>'
604 . '</dl>'
605 . '<h3>Versand</h3><dl>'
606 . '<dt>Digitale Downloads aktiv</dt><dd>Erlaubt digitale Bereitstellung fuer passende Produkte. Digitale Produkte koennen andere Widerrufsregeln haben; die Rechtstexte muessen dazu passen.</dd>'
607 . '<dt>Pauschalversand aktiv</dt><dd>Aktiviert einen einfachen globalen Versandkostenwert als Fallback. Detailliertere Versandlogik wird weiterhin ueber Versandgruppen gepflegt.</dd>'
608 . '<dt>Pauschalversand brutto</dt><dd>Globaler Bruttobetrag fuer Versandkosten, wenn keine spezifischere Versandgruppe greift. Beispiel: <code>5.90</code>.</dd>'
609 . '<dt>CMS-Media Slot</dt><dd>Technischer Slotname fuer Shop-Medien im CMS-Medienbrowser. Normalerweise <code>shop</code>. Nur aendern, wenn Shop-Bilder bewusst in einem anderen CMS-Slot verwaltet werden sollen.</dd>'
610 . '</dl>'
611 . '<h3>Speichern</h3>'
612 . '<p>Nach dem Speichern werden die Werte in der Modul-Konfiguration <code>dbxShop</code> abgelegt. Aenderungen an MwSt.-Prozentwerten wirken sofort auf Artikel, die ihre MwSt. aus Artikelgruppen erben.</p>'
613 . '</section>';
614 }
615
616 private function ordersHelpHtml(): string {
617 return '<section class="dbx-shop-help">'
618 . '<h2>Bestellungen</h2>'
619 . '<p>Die Bestellverwaltung zeigt Shop-, PayPal- und Channel-Bestellungen als Snapshots. Eine Bestellung soll nicht aus aktuellen Artikeldaten rekonstruiert werden, sondern behält Artikel, Preise, MwSt., Versand und Zahlungsdaten aus dem Zeitpunkt der Bestellung.</p>'
620 . '<h3>Liste</h3><dl>'
621 . '<dt>Bestellung</dt><dd>Bestellnummer, Datum und Herkunft. Die Bestellnummer bleibt stabil und dient fuer Kundensuche, Zahlungsabgleich und Channel-Zuordnung.</dd>'
622 . '<dt>Kunde</dt><dd>Name und E-Mail-Adresse, soweit beim Checkout oder beim Channel-Import vorhanden. Bei importierten Marktplatzbestellungen kann die Plattform je nach Datenschutz nur eingeschraenkte Kundendaten liefern.</dd>'
623 . '<dt>Status</dt><dd>Interner Bearbeitungsstatus: neu, Zahlung offen, bezahlt, in Bearbeitung, versendet, abgeschlossen oder storniert. Dieser Status steuert die Arbeit im Shop-Admin.</dd>'
624 . '<dt>Zahlung</dt><dd>Zahlungsanbieter, Zahlungsstatus und Referenz, z.B. PayPal-ID oder externe Channel-Bestellnummer.</dd>'
625 . '<dt>Positionen</dt><dd>Artikelpositionen als Snapshot mit SKU, Titel und Menge.</dd>'
626 . '<dt>Summe</dt><dd>Bruttosumme der Bestellung in der gespeicherten Waehrung.</dd>'
627 . '</dl>'
628 . '<h3>Filter</h3><dl>'
629 . '<dt>Suche</dt><dd>Sucht nach Bestellnummer, Kunde, E-Mail, Channel und Zahlungsreferenz.</dd>'
630 . '<dt>Status</dt><dd>Filtert nach internem Bearbeitungsstatus.</dd>'
631 . '<dt>Zahlung</dt><dd>Filtert nach Zahlungsstatus.</dd>'
632 . '<dt>Versand</dt><dd>Filtert nach operativem Versandstatus, zum Beispiel offen, versendet oder zugestellt.</dd>'
633 . '<dt>Channel</dt><dd>Filtert nach Herkunft, z.B. Shop, eBay, Amazon oder mobile.de.</dd>'
634 . '</dl>'
635 . '<h3>Detail bearbeiten</h3><dl>'
636 . '<dt>Schnellaktionen</dt><dd>Setzen haeufige Arbeitsschritte mit einem Klick: bezahlt, in Bearbeitung, versandbereit, versendet, zugestellt/abgeschlossen, storniert oder erstattet. Jede Aktion schreibt die passenden Statusfelder und einen Historieneintrag.</dd>'
637 . '<dt>Kundenmail senden</dt><dd>Der Umschlag-Button in der Bar sendet den aktuell gespeicherten Status an die Kunden-E-Mail-Adresse. Speichern Sie geaenderte Felder zuerst. Voraussetzung ist ein Mail-Absender in den Shop-Einstellungen.</dd>'
638 . '<dt>Status</dt><dd>Setzt den internen Bearbeitungsstand. Typischer Ablauf: neu -> bezahlt -> in Bearbeitung -> versendet -> abgeschlossen.</dd>'
639 . '<dt>Zahlungsstatus</dt><dd>Dokumentiert den Stand der Zahlung. PayPal und Channel-Import koennen diesen Wert automatisch setzen; manuelle Korrektur ist fuer Abgleich und Sonderfaelle moeglich.</dd>'
640 . '<dt>Zahlungsreferenz</dt><dd>Externe Referenz wie PayPal Order ID, eBay Order ID, Amazon Order ID oder interne Buchungsnummer.</dd>'
641 . '<dt>Rechnungsnummer / Rechnungsdatum</dt><dd>Werden manuell gepflegt oder beim Wechsel in einen bezahlten Bearbeitungsstatus automatisch vorgeschlagen. Fuer echten Livebetrieb muss ein rechtssicherer Nummernkreis definiert werden.</dd>'
642 . '<dt>Versandstatus</dt><dd>Operativer Lieferstatus: offen, bereit, versendet, zugestellt oder Retoure.</dd>'
643 . '<dt>Versanddienstleister</dt><dd>Name des Dienstleisters oder der Versandart, z.B. DHL, UPS, Deutsche Post oder Download/Freischaltung.</dd>'
644 . '<dt>Trackingnummer / Tracking-URL</dt><dd>Trackingdaten fuer physische Sendungen. Wenn DHL, UPS, DPD oder Hermes als Versanddienstleister eingetragen ist und die Tracking-URL leer bleibt, wird beim Speichern eine passende Tracking-URL erzeugt.</dd>'
645 . '<dt>Versendet am</dt><dd>Zeitpunkt des Versands. Wird beim Wechsel auf <code>Versendet</code> automatisch gesetzt, wenn noch leer.</dd>'
646 . '<dt>Admin-Notiz</dt><dd>Interne Notizen fuer Rueckfragen, Versandhinweise, Sonderabsprachen oder Channel-Probleme.</dd>'
647 . '<dt>Rechnung anzeigen / PDF</dt><dd>Der Beleg-Button zeigt eine Rechnungsvorschau. Der PDF-Button erzeugt bei Bedarf Rechnungsnummer, Datum und PDF-Datei und archiviert den Dateipfad an der Bestellung.</dd>'
648 . '<dt>Positionen</dt><dd>Nicht direkt editierbare Snapshot-Positionen. Aenderungen an Artikeln sollen alte Bestellungen nicht veraendern.</dd>'
649 . '<dt>Payload</dt><dd>Rohdaten aus Zahlung oder Channel-Import. Diese helfen bei technischer Pruefung und Supportfaellen.</dd>'
650 . '<dt>Historie</dt><dd>Protokolliert wichtige Status-, Zahlungs-, Versand-, Rechnungs- und Widerrufsereignisse.</dd>'
651 . '<dt>Widerrufe</dt><dd>Zeigt eingegangene Widerrufe, die anhand der Bestellnummer zugeordnet wurden.</dd>'
652 . '</dl>'
653 . '<h3>Loeschen</h3>'
654 . '<p>Loeschen verschiebt Bestellungen in den Papierkorb. Es loescht keine Artikel und keine Channel-Daten. Fuer echte kaufmaennische Loesch-/Aufbewahrungsregeln muss vor Livebetrieb entschieden werden, was archiviert werden muss.</p>'
655 . '</section>';
656 }
657
658 private function productsHelpHtml(): string {
659 return '<section class="dbx-shop-help">'
660 . '<h2>Produkte und Artikel-Formular</h2>'
661 . '<p>Das Produktformular pflegt die Stammdaten eines Artikels. Gruppen, Attribute, Bilder und Channel-Zuordnungen sind getrennte Bereiche, damit ein Artikel sauber im Shop angezeigt, verkauft und optional zu externen Plattformen exportiert werden kann.</p>'
662 . '<h3>Produktliste</h3><dl>'
663 . '<dt>Suche / Filter</dt><dd>Sucht nach Artikelnummer, Titel, Beschreibung, Gruppe, Channel und weiteren Produktwerten. Die Anzahl pro Seite steuert die Tabellenlaenge; <code>Alle</code> zeigt alle passenden Artikel.</dd>'
664 . '<dt>Bearbeiten</dt><dd>Oeffnet das Artikel-Formular im Fenster.</dd>'
665 . '<dt>Ansehen</dt><dd>Oeffnet die Shop-Ansicht des Artikels.</dd>'
666 . '<dt>Loeschen</dt><dd>Verschiebt den Artikel nach Confirm in den Papierkorb.</dd>'
667 . '<dt>Mehrfachaktion</dt><dd>Fuer markierte Artikel koennen Channel hinzugefuegt, entfernt, exportiert oder Produktgruppen gesetzt werden.</dd>'
668 . '</dl>'
669 . '<h3>Grunddaten</h3><dl>'
670 . '<dt>Artikelnummer</dt><dd>Interne und externe Artikelnummer. Sie entspricht praktisch der SKU und sollte eindeutig, stabil und kanalgeeignet sein.</dd>'
671 . '<dt>URL-Name</dt><dd>Technischer URL-Teil fuer die Shop-Detailseite. Verwenden Sie kurze, sprechende Kleinbuchstaben ohne Sonderzeichen.</dd>'
672 . '<dt>Titel</dt><dd>Sichtbarer Produktname im Shop, in Listen und beim Export.</dd>'
673 . '<dt>Kategorie</dt><dd>Freie Kategoriebezeichnung fuer Darstellung und Verwaltung.</dd>'
674 . '<dt>Produkttyp</dt><dd>Unterscheidet digitale Produkte, physische Produkte und Dienstleistungen. Das wirkt auf Versand, Checkout und spaetere Channel-Logik.</dd>'
675 . '<dt>Aktiv</dt><dd>Nur aktive Produkte werden regulaer angeboten.</dd>'
676 . '<dt>Sortierung</dt><dd>Reihenfolge in Listen und Katalogen. Kleine Werte stehen weiter vorne.</dd>'
677 . '</dl>'
678 . '<h3>Beschreibung</h3><dl>'
679 . '<dt>Kurzbeschreibung</dt><dd>Kompakter Text fuer Karten, Listen und Suchergebnisse.</dd>'
680 . '<dt>Beschreibung</dt><dd>Ausfuehrlicher Produkttext fuer Detailseite und Channel-Export.</dd>'
681 . '</dl>'
682 . '<h3>Preis, Versand und Darstellung</h3><dl>'
683 . '<dt>Bruttopreis</dt><dd>Artikelpreis inklusive MwSt. Die MwSt.-Prozentwerte kommen aus den Shop-Einstellungen und der Artikelgruppe.</dd>'
684 . '<dt>Versand-Quelle</dt><dd><code>Aus Gruppe</code> nutzt die Versandgruppe. Individuelle Werte ueberschreiben die Gruppe fuer diesen Artikel.</dd>'
685 . '<dt>Versand brutto individuell</dt><dd>Artikelbezogener Versandwert. <code>-1</code> bedeutet: aus Gruppe oder globalem Fallback berechnen.</dd>'
686 . '<dt>Lagerbestand</dt><dd>Interner Bestand. Fuer digitale Produkte kann 0 trotzdem verkaufbar sein, wenn der Ablauf das erlaubt.</dd>'
687 . '<dt>Badge</dt><dd>Kurzer Hervorhebungs-Text auf Karten, z.B. <code>Neu</code> oder <code>Business</code>.</dd>'
688 . '<dt>Icon</dt><dd>Bootstrap-Icon-Klasse fuer fallbackartige Darstellung ohne Bild.</dd>'
689 . '<dt>Logo-Variante</dt><dd>Optionaler Darstellungswert fuer Templates.</dd>'
690 . '<dt>MwSt.-Quelle</dt><dd>Normalfall ist <code>Aus Gruppe</code>. Die Gruppe referenziert <code>mwst1</code>, <code>mwst2</code> oder <code>mwst3</code>; die echten Prozentwerte stehen in den Shop-Einstellungen.</dd>'
691 . '</dl>'
692 . '<h3>Bilder</h3><dl>'
693 . '<dt>Auswahl</dt><dd>Oeffnet den CMS-Medienbrowser fuer Shop-Bilder. Dateien bleiben im Medienbrowser; im Shop wird nur die Zuordnung gespeichert.</dd>'
694 . '<dt>Primaerbild</dt><dd>Markiert das neu zugeordnete Bild als Hauptbild.</dd>'
695 . '<dt>Papierkorb am Bild</dt><dd>Hebt nur die Shop-Zuordnung auf. Die Mediendatei wird nicht geloescht.</dd>'
696 . '</dl>'
697 . '<h3>Channels</h3><dl>'
698 . '<dt>Checkbox</dt><dd>Setzt eine direkte Artikel-Channel-Zuordnung. Diese hat Vorrang vor Channel-Gruppen.</dd>'
699 . '<dt>Status</dt><dd>Zeigt, ob der Channel direkt aktiv, direkt deaktiviert, aus einer Gruppe geerbt oder nicht gesetzt ist.</dd>'
700 . '<dt>Mapping</dt><dd>Oeffnet das produktbezogene Channel-Mapping fuer Kategorie, Pflichtattribute, externe IDs und provider-spezifische Werte.</dd>'
701 . '<dt>Export</dt><dd>Startet den Export fuer diesen Artikel und Channel, wenn der Channel Export erlaubt und ausreichend konfiguriert ist.</dd>'
702 . '</dl>'
703 . '<h3>Bar-Aktionen</h3><dl>'
704 . '<dt>Speichern</dt><dd>Speichert Stammdaten und direkte Channel-Auswahl.</dd>'
705 . '<dt>Artikel ansehen</dt><dd>Oeffnet die Shop-Vorschau.</dd>'
706 . '<dt>Artikelattribute</dt><dd>Oeffnet die Werte der gruppenspezifischen Attribute fuer diesen Artikel.</dd>'
707 . '<dt>Loeschen</dt><dd>Loescht nach Confirm den Artikel.</dd>'
708 . '<dt>Hilfe</dt><dd>Oeffnet diese CMS-Hilfeseite.</dd>'
709 . '<dt>Produktliste</dt><dd>Zurueck zur Produktuebersicht.</dd>'
710 . '</dl>'
711 . '</section>';
712 }
713
714 private function productChannelMappingHelpHtml(): string {
715 return '<section class="dbx-shop-help">'
716 . '<h2>Channel-Mapping je Artikel</h2>'
717 . '<p>Das Channel-Mapping speichert produktbezogene Werte fuer genau einen Artikel und genau einen Channel. Globale Zugangsdaten bleiben im Channel-Formular; hier stehen die Werte, die sich pro Artikel unterscheiden.</p>'
718 . '<h3>Channel-Werte</h3><dl>'
719 . '<dt>Aktiv</dt><dd>Schaltet die direkte Artikelzuordnung fuer diesen Channel ein. Diese Einstellung hat Vorrang vor Channel-Gruppen.</dd>'
720 . '<dt>Channel-SKU</dt><dd>Artikelnummer, die fuer diesen Channel verwendet wird. Meist identisch mit der Shop-Artikelnummer; bei Plattformen kann sie aber abweichen.</dd>'
721 . '<dt>Channel-Preis</dt><dd>Brutto-Preis, der fuer diesen Channel verwendet wird. Wenn kein eigener Channel-Preis gespeichert ist, zeigt das Feld den aktuellen Shop-Artikelpreis an. Wird dieser Wert unveraendert gespeichert, bleibt intern die Vererbung aktiv. Aendern Sie den Wert nur, wenn dieser Channel bewusst einen abweichenden Preis bekommen soll.</dd>'
722 . '<dt>Channel-Versand</dt><dd>Versandwert, der fuer diesen Channel verwendet wird. Ohne eigenen Channel-Wert wird der wirksame Artikel-/Gruppenversand angezeigt und intern weiter geerbt. Ein abweichender Wert wird nur gespeichert, wenn Sie ihn hier bewusst aendern.</dd>'
723 . '<dt>Listing-ID</dt><dd>Externe ID des sichtbaren Angebots auf der Plattform. Bei eBay entsteht sie normalerweise erst nach dem Publish des Offers. Dieses Feld wird im Normalfall vom Connector nach erfolgreichem Export/Rueckmeldung gefuellt. Manuell eintragen sollten Sie sie nur, wenn ein bereits extern vorhandenes Angebot mit diesem Shop-Artikel verknuepft oder eine Zuordnung repariert werden muss.</dd>'
724 . '<dt>Offer-ID</dt><dd>Externe Angebots-/Offer-ID der Plattform. Bei eBay ist das Offer der Zwischenschritt zwischen Inventory Item/SKU und dem veroeffentlichten Listing. Die Offer-ID braucht der Connector spaeter zum Aktualisieren, Publishen, Beenden oder Pruefen genau dieses Offers. Bei neuen Artikeln bleibt das Feld leer; nach erfolgreicher Erstellung speichert der Connector die ID automatisch.</dd>'
725 . '</dl>'
726 . '<h3>Provider-Mapping</h3><dl>'
727 . '<dt>Shop / Custom Aktion</dt><dd>Interner oder Middleware-Endpunkt, an den der Export uebergeben werden soll.</dd>'
728 . '<dt>eBay Kategorie-ID</dt><dd>eBay Kategorie fuer das Angebot. Sie muss zum Produkt passen, sonst lehnt eBay Pflichtattribute oder das Listing ab.</dd>'
729 . '<dt>eBay Location-Key</dt><dd>Merchant Location Key des eBay-Verkaeuferkontos. Dieser Wert kommt aus der eBay-Channel-Konfiguration und wird nicht pro Artikel gepflegt. Wenn sich Lager- oder Versandort aendert, wird der Wert in der Channel-Verwaltung geaendert und gilt dann fuer alle eBay-Mappings ohne eigene Sonderlogik.</dd>'
730 . '<dt>eBay Zustand</dt><dd>Condition-Code oder Zustand des Artikels, z.B. neu oder gebraucht.</dd>'
731 . '<dt>eBay Policies</dt><dd>Payment-, Fulfillment- und Return-Policy-IDs. Sie bestimmen Zahlung, Versand und Rueckgabe fuer genau dieses Listing.</dd>'
732 . '<dt>eBay Aspekte</dt><dd>Pflicht- und Zusatzattribute als <code>key=value</code>, z.B. <code>Marke=dbxApp</code>. Ein Wert pro Zeile.</dd>'
733 . '<dt>Amazon Product Type</dt><dd>Amazon Produkttyp fuer die Listings Items API, z.B. <code>PRODUCT</code> oder eine spezifische Kategorie.</dd>'
734 . '<dt>Amazon Requirements</dt><dd>Steuert, welche Anforderungen Amazon beim Listing erwartet, z.B. <code>LISTING</code> oder <code>LISTING_PRODUCT_ONLY</code>.</dd>'
735 . '<dt>Amazon Brand / Attribute</dt><dd>Marke und weitere Attribute als <code>key=value</code>. Welche Werte Pflicht sind, haengt vom Product Type ab.</dd>'
736 . '<dt>mobile.de Fahrzeugdaten</dt><dd>mobile.de ist fuer Fahrzeuge gedacht. Marke, Modell, Erstzulassung, Kilometer, Kraftstoff und Leistung muessen zum Fahrzeug passen.</dd>'
737 . '<dt>Kleinanzeigen</dt><dd>Offiziell nur ueber Partner-/Middleware-Schnittstelle sinnvoll. Kategorie, Ort, Kontakt und Attribute werden an die Middleware gegeben.</dd>'
738 . '</dl>'
739 . '<h3>Exportstatus</h3><dl>'
740 . '<dt>Status</dt><dd>Letzter technischer Exportzustand, z.B. veroeffentlicht, bereit, manuell bereit oder Fehler.</dd>'
741 . '<dt>Meldung</dt><dd>Antwort oder Fehlermeldung des Connectors.</dd>'
742 . '<dt>Payload</dt><dd>Gespeicherte technische Antwortdaten zur Fehlersuche.</dd>'
743 . '</dl>'
744 . '<h3>Bar-Aktionen</h3><dl>'
745 . '<dt>Speichern</dt><dd>Speichert Mapping und Channel-Werte.</dd>'
746 . '<dt>Exportieren</dt><dd>Startet den Export dieses Artikels fuer diesen Channel.</dd>'
747 . '<dt>Hilfe</dt><dd>Oeffnet diese CMS-Hilfeseite.</dd>'
748 . '</dl>'
749 . '</section>';
750 }
751
752 private function productAttributesHelpHtml(): string {
753 return '<section class="dbx-shop-help">'
754 . '<h2>Artikelattribute</h2>'
755 . '<p>Artikelattribute beschreiben gruppenspezifische Merkmale wie Groesse, Farbe, Material oder technische Daten. Die Definition entsteht bei der Artikelgruppe; im Artikel werden nur die Werte gepflegt.</p>'
756 . '<h3>Attribut-Definition</h3><dl>'
757 . '<dt>Gruppe</dt><dd>Artikelgruppe, fuer die das Attribut gilt.</dd>'
758 . '<dt>Key</dt><dd>Technischer Schluessel, z.B. <code>farbe</code>. Er sollte stabil bleiben.</dd>'
759 . '<dt>Titel</dt><dd>Sichtbarer Name des Attributs.</dd>'
760 . '<dt>Typ</dt><dd>Text, Zahl oder Auswahlliste.</dd>'
761 . '<dt>Einheit</dt><dd>Optionale Einheit, z.B. cm, kg, Watt.</dd>'
762 . '<dt>Optionen</dt><dd>Werte fuer Auswahllisten, getrennt durch <code>|</code>.</dd>'
763 . '<dt>Pflicht</dt><dd>Markiert Werte, die fuer vollstaendige Produktdaten erwartet werden.</dd>'
764 . '<dt>Filter</dt><dd>Kann spaeter fuer Katalogfilter verwendet werden.</dd>'
765 . '<dt>Vergleich</dt><dd>Kann spaeter fuer Produktvergleiche verwendet werden.</dd>'
766 . '<dt>Sort / Aktiv</dt><dd>Reihenfolge und Freigabe des Attributs.</dd>'
767 . '</dl>'
768 . '<h3>Artikelwert</h3><dl>'
769 . '<dt>Wert</dt><dd>Konkreter Wert dieses Artikels fuer das Attribut.</dd>'
770 . '<dt>Einheit</dt><dd>Wird aus der Definition angezeigt.</dd>'
771 . '<dt>Eigenschaft</dt><dd>Zeigt Pflicht-/Filter-Hinweise aus der Definition.</dd>'
772 . '</dl>'
773 . '<h3>Aktionen</h3><dl>'
774 . '<dt>Speichern</dt><dd>Speichert die Attributdefinition oder die Artikelwerte.</dd>'
775 . '<dt>Zurueck</dt><dd>Fuehrt zur Produktliste zurueck.</dd>'
776 . '<dt>Hilfe</dt><dd>Oeffnet diese CMS-Hilfeseite.</dd>'
777 . '</dl>'
778 . '</section>';
779 }
780
781 private function shopMediaHelpHtml(): string {
782 return '<section class="dbx-shop-help">'
783 . '<h2>Shop-Medien</h2>'
784 . '<p>Shop-Medien ordnen vorhandene CMS-Medien Artikeln oder Artikelgruppen zu. Die Datei bleibt im CMS-Medienbrowser; dbxShop speichert nur die Verwendung im Shop.</p>'
785 . '<h3>Felder</h3><dl>'
786 . '<dt>Artikel</dt><dd>Optionaler einzelner Artikel, dem neue Bildzuordnungen hinzugefuegt werden.</dd>'
787 . '<dt>Artikelgruppe</dt><dd>Optionale Gruppe. Gruppenbilder koennen bei Artikeln dieser Gruppe als Fallback erscheinen.</dd>'
788 . '<dt>Sortierung</dt><dd>Reihenfolge der Bilder.</dd>'
789 . '<dt>Primaerbild</dt><dd>Markiert die neue Zuordnung als Hauptbild.</dd>'
790 . '</dl>'
791 . '<h3>Aktionen</h3><dl>'
792 . '<dt>Auswahl laden</dt><dd>Laedt die aktuell gewaehlt Artikel-/Gruppenauswahl neu.</dd>'
793 . '<dt>Auswahl</dt><dd>Oeffnet den CMS-Medienbrowser fuer Upload, Auswahl, Zuschneiden, Resize, Batch und Wartung.</dd>'
794 . '<dt>Zugeordnete Shop-Bilder</dt><dd>Zeigt vorhandene Artikel- und Gruppenbild-Zuordnungen.</dd>'
795 . '<dt>Hilfe</dt><dd>Oeffnet diese CMS-Hilfeseite.</dd>'
796 . '</dl>'
797 . '</section>';
798 }
799
800 private function loadContentCacheSupport(): void {
801 $bootstrap = dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap.php';
802 if (is_file($bootstrap)) {
803 require_once $bootstrap;
804 }
805 $cacheFile = dirname(__DIR__, 2) . '/dbxContent/include/dbxContentPageCache.class.php';
806 if (is_file($cacheFile)) {
807 require_once $cacheFile;
808 }
809 $indexFile = dirname(__DIR__, 2) . '/dbxContent/include/dbxContentPermalinkIndex.class.php';
810 if (is_file($indexFile)) {
811 require_once $indexFile;
812 }
813 }
814
815 private function invalidateShopHelpCache(int $cid): void {
816 if ($cid <= 0) {
817 return;
818 }
819 $this->loadContentCacheSupport();
820 if (class_exists('\\dbx\\dbxContent\\dbxContentPageCache')) {
822 }
823 }
824
825 private function syncShopHelpPermalinkIndex(int $cid, string $permalink, string $rights = 'admin'): void {
826 if ($cid <= 0 || $permalink === '') {
827 return;
828 }
829 $this->loadContentCacheSupport();
830 if (class_exists('\\dbx\\dbxContent\\dbxContentPermalinkIndex')) {
831 \dbx\dbxContent\dbxContentPermalinkIndex::upsertPage($cid, $permalink, $rights, 1);
832 }
833 }
834
835 private function removeShopHelpPermalinkIndex(string $permalink): void {
836 $permalink = trim($permalink);
837 if ($permalink === '') {
838 return;
839 }
840 $this->loadContentCacheSupport();
841 if (class_exists('\\dbx\\dbxContent\\dbxContentPermalinkIndex')) {
843 }
844 }
845
846 private function ensureShopChannelHelpPage(): int {
847 return $this->ensureShopAdminHelpPage('help-shop-channel-gruppen', 'Hilfe: Channel-Gruppen', 'Hilfe zur Pflege von Shop Channel-Gruppen.', 'shop,channel,channel-gruppen,hilfe', $this->channelGroupsHelpHtml(), '9010', array('shop/help-channel-groups', 'shop/channel'));
848 }
849
850 private function ensureShopChannelsHelpPage(): int {
851 return $this->ensureShopAdminHelpPage('help-shop-channels', 'Hilfe: Channels', 'Hilfe zur Pflege von Shop Channels und Marktplatz-Anbindungen.', 'shop,channels,api,marktplatz,hilfe', $this->channelsHelpHtml(), '9015', array('shop/help-channels', 'shop/channels'));
852 }
853
854 private function ensureShopChannelProviderHelpPage(string $platform): int {
855 $allowed = array('shop', 'amazon', 'ebay', 'kleinanzeigen', 'mobile', 'custom');
856 if (!in_array($platform, $allowed, true)) {
857 $platform = 'custom';
858 }
859 $titles = array(
860 'shop' => 'Hilfe: Channel Shop',
861 'amazon' => 'Hilfe: Channel Amazon',
862 'ebay' => 'Hilfe: Channel eBay',
863 'kleinanzeigen' => 'Hilfe: Channel Kleinanzeigen',
864 'mobile' => 'Hilfe: Channel mobile.de',
865 'custom' => 'Hilfe: Eigener Channel',
866 );
867 $sort = array('shop' => '9016', 'amazon' => '9017', 'ebay' => '9018', 'kleinanzeigen' => '9019', 'mobile' => '9024', 'custom' => '9025');
868 return $this->ensureShopAdminHelpPage(
869 'help-shop-channel-' . $platform,
870 $titles[$platform],
871 'Hilfe zu API-Daten und Rueckmeldungen fuer ' . $titles[$platform] . '.',
872 'shop,channels,' . $platform . ',api,hilfe',
873 $this->channelProviderHelpHtml($platform),
874 $sort[$platform],
875 array('shop/help-channel-' . $platform, 'shop/channels/' . $platform)
876 );
877 }
878
879 private function ensureShopProductGroupsHelpPage(): int {
880 return $this->ensureShopAdminHelpPage('help-shop-artikelgruppen', 'Hilfe: Artikelgruppen', 'Hilfe zur Pflege von Shop Artikelgruppen.', 'shop,artikelgruppen,produktgruppen,hilfe', $this->productGroupsHelpHtml(), '9020', array('shop/help-groups', 'shop/groups'));
881 }
882
883 private function ensureShopShippingGroupsHelpPage(): int {
884 return $this->ensureShopAdminHelpPage('help-shop-versandgruppen', 'Hilfe: Versandgruppen', 'Hilfe zur Pflege von Shop Versandgruppen.', 'shop,versandgruppen,shipping,hilfe', $this->shippingGroupsHelpHtml(), '9030', array('shop/help-shipping-groups', 'shop/shipping-groups'));
885 }
886
887 private function ensureShopSettingsHelpPage(): int {
888 return $this->ensureShopAdminHelpPage('help-shop-einstellungen', 'Hilfe: Shop-Einstellungen', 'Hilfe zur Pflege der globalen Shop-Einstellungen.', 'shop,einstellungen,mwst,zahlung,versand,hilfe', $this->settingsHelpHtml(), '9040', array('shop/help-settings', 'shop/settings'));
889 }
890
891 private function ensureShopOrdersHelpPage(): int {
892 return $this->ensureShopAdminHelpPage('help-shop-bestellungen', 'Hilfe: Bestellungen', 'Hilfe zur Bearbeitung von Shop- und Channel-Bestellungen.', 'shop,bestellungen,orders,zahlung,channel,hilfe', $this->ordersHelpHtml(), '9050', array('shop/help-orders', 'shop/orders'));
893 }
894
895 private function ensureShopProductsHelpPage(): int {
896 return $this->ensureShopAdminHelpPage('help-shop-produkte', 'Hilfe: Produkte', 'Hilfe zur Produktliste und zum Artikel-Formular.', 'shop,produkte,artikel,formular,hilfe', $this->productsHelpHtml(), '9005', array('shop/help-products', 'shop/products'));
897 }
898
899 private function ensureShopProductChannelMappingHelpPage(): int {
900 return $this->ensureShopAdminHelpPage('help-shop-produkt-channel-mapping', 'Hilfe: Channel-Mapping je Artikel', 'Hilfe zur produktbezogenen Channel-Mapping-Maske.', 'shop,produkte,channels,mapping,hilfe', $this->productChannelMappingHelpHtml(), '9014', array('shop/help-product-channel-mapping', 'shop/product-channel-mapping'));
901 }
902
903 private function ensureShopProductAttributesHelpPage(): int {
904 return $this->ensureShopAdminHelpPage('help-shop-artikelattribute', 'Hilfe: Artikelattribute', 'Hilfe zu Attributdefinitionen und Artikelwerten.', 'shop,produkte,attribute,hilfe', $this->productAttributesHelpHtml(), '9028', array('shop/help-product-attributes', 'shop/product-attributes'));
905 }
906
907 private function ensureShopMediaHelpPage(): int {
908 return $this->ensureShopAdminHelpPage('help-shop-medien', 'Hilfe: Shop-Medien', 'Hilfe zur Medienzuordnung fuer Shop-Artikel und Artikelgruppen.', 'shop,medien,bilder,hilfe', $this->shopMediaHelpHtml(), '9035', array('shop/help-media', 'shop/media-help'));
909 }
910
911 private function ensureShopAdminHelpPage(string $permalink, string $title, string $description, string $keywords, string $content, string $sorter, array $oldPermalinks = array()): int {
912 $db = $this->db();
913 $contentDd = $this->contentDd();
914 try {
915 $permalinks = array_values(array_unique(array_merge(array($permalink), $oldPermalinks)));
916 foreach ($permalinks as $candidatePermalink) {
917 $candidatePermalink = trim((string)$candidatePermalink);
918 if ($candidatePermalink === '') {
919 continue;
920 }
921 $row = $db->select1($contentDd, array('permalink' => $candidatePermalink), 'id,content,permalink,title,description,keywords,sorter', 0);
922 if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
923 $id = (int)$row['id'];
924 if (!$this->maintenanceMode) {
925 return $id;
926 }
927 $needsUpdate = trim((string)($row['content'] ?? '')) !== trim($content)
928 || trim((string)($row['permalink'] ?? '')) !== $permalink
929 || trim((string)($row['title'] ?? '')) !== $title
930 || trim((string)($row['description'] ?? '')) !== $description
931 || trim((string)($row['keywords'] ?? '')) !== $keywords
932 || trim((string)($row['sorter'] ?? '')) !== $sorter
933 || $candidatePermalink !== $permalink;
934 if ($needsUpdate) {
935 $this->invalidateShopHelpCache($id);
936 $db->update($contentDd, array(
937 'permalink' => $permalink,
938 'title' => $title,
939 'description' => $description,
940 'keywords' => $keywords,
941 'sorter' => $sorter,
942 'content' => $content,
943 'group_read' => 'admin',
944 'activ' => 1,
945 'addmenu' => 0,
946 'meta_robots' => 'noindex,nofollow',
947 ), $id);
948 }
949 foreach ($oldPermalinks as $oldPermalink) {
950 $this->removeShopHelpPermalinkIndex((string)$oldPermalink);
951 }
952 $this->syncShopHelpPermalinkIndex($id, $permalink, 'admin');
953 return $id;
954 }
955 }
956
957 if (!$this->maintenanceMode) {
958 return 0;
959 }
960
961 $folderId = $this->ensureShopHelpFolder();
962 $insert = array(
963 'activ' => 1,
964 'addmenu' => 0,
965 'folder' => $folderId,
966 'group_read' => 'admin',
967 'sorter' => $sorter,
968 'title' => $title,
969 'permalink' => $permalink,
970 'description' => $description,
971 'keywords' => $keywords,
972 'template' => 'c-body1-footer',
973 'content' => $content,
974 'meta_robots' => 'noindex,nofollow',
975 );
976 $ok = (int)$db->insert($contentDd, $insert);
977 if ($ok === 1) {
978 $id = (int)$db->get_insert_id();
979 $this->invalidateShopHelpCache($id);
980 $this->syncShopHelpPermalinkIndex($id, $permalink, 'admin');
981 return $id;
982 }
983 } catch (\Throwable $e) {
984 if (function_exists('dbx')) {
985 dbx()->debug('dbxShop help page failed', $e->getMessage());
986 }
987 }
988
989 return 0;
990 }
991
992 private function ensureShopMediaUsagePage(): int {
993 $db = $this->db();
994 $contentDd = $this->shopMediaUsageContentDd();
995 try {
996 $row = $db->select1($contentDd, array('permalink' => 'shop-medienverwendung'), 'id', 0);
997 if (!is_array($row)) {
998 $row = $db->select1($contentDd, array('permalink' => 'outside/shop-media-usage'), 'id', 0);
999 if ($this->maintenanceMode && is_array($row) && (int)($row['id'] ?? 0) > 0) {
1000 $db->update($contentDd, array('permalink' => 'shop-medienverwendung'), (int)$row['id']);
1001 }
1002 }
1003 if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
1004 return (int)$row['id'];
1005 }
1006
1007 if (!$this->maintenanceMode) {
1008 return 0;
1009 }
1010
1011 $folderId = 0;
1012 $folder = $db->select1($this->shopMediaUsageFolderDd(), array('name' => 'outside'), 'id', 0);
1013 if (is_array($folder)) {
1014 $folderId = (int)($folder['id'] ?? 0);
1015 }
1016
1017 $insert = array(
1018 'activ' => 0,
1019 'addmenu' => 0,
1020 'folder' => $folderId,
1021 'group_read' => 'admin',
1022 'sorter' => '9999',
1023 'title' => 'Shop Medienverwendung',
1024 'permalink' => 'shop-medienverwendung',
1025 'description' => 'Interne Seite fuer Shop-Medienverwendung.',
1026 'keywords' => '',
1027 'template' => 'c-body1-footer',
1028 'content' => '<p>Interne Seite fuer Shop-Medienverwendung.</p>',
1029 'meta_robots' => 'noindex,nofollow',
1030 );
1031 $ok = (int)$db->insert($contentDd, $insert);
1032 if ($ok === 1) {
1033 $id = (int)$db->get_insert_id();
1034 return $id;
1035 }
1036 } catch (\Throwable $e) {
1037 if (function_exists('dbx')) {
1038 dbx()->debug('dbxShop media_usage page failed', $e->getMessage());
1039 }
1040 }
1041
1042 return 0;
1043 }
1044
1045 private function shopMediaUsageSlot(): string {
1046 $slot = strtolower(trim((string)dbx()->get_config('dbxShop', 'media_usage_slot')));
1047 $allowed = array('shop','hero','gallery','inline','header','teaser','footer');
1048 return in_array($slot, $allowed, true) ? $slot : 'shop';
1049 }
1050
1051 private function shopMediaUsageSorter($db, int $contentId, string $slot): string {
1052 $where = dbxContentMediaUsageScope::withLanguage(
1053 'content_id = ' . $contentId . " AND slot = '" . str_replace("'", "''", $slot) . "' AND active = 1",
1054 $this->shopMediaUsageLng()
1055 );
1056 $rows = $db->select('dbxMediaUsage', $where, 'sorter,id', 'sorter,id', 'DESC', '', 1, 0, 0);
1057 $max = 0;
1058 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
1059 $max = (int)($rows[0]['sorter'] ?? 0);
1060 }
1061 return sprintf('%04d', $max + 10);
1062 }
1063
1064 private function shopMediaFolderPath(): string {
1065 $base = dbx()->get_file_dir();
1066 return rtrim($base, '/\\') . '/media/img/shop';
1067 }
1068
1069 private function normalizeShopSourceImagePath(string $path): string {
1070 $path = trim(str_replace('\\', '/', $path));
1071 $path = preg_replace('~^https?://[^/]+/~i', '', $path) ?: $path;
1072 $path = preg_replace('~^/?dbxapp/~i', '', $path) ?: $path;
1073 return ltrim($path, '/');
1074 }
1075
1076 private function filePathForShopImage(string $path): string {
1077 $path = $this->normalizeShopSourceImagePath($path);
1078 if ($path === '' || strpos($path, '..') !== false || preg_match('~(^|/)\.~', $path)) {
1079 return '';
1080 }
1081 if (preg_match('~^files/(.+)$~i', $path, $m)) {
1082 return rtrim((string)dbx()->get_file_dir(), '/\\') . '/' . $m[1];
1083 }
1084 if (preg_match('~^(media|shop)/~i', $path)) {
1085 return rtrim((string)dbx()->get_file_dir(), '/\\') . '/' . $path;
1086 }
1087 return '';
1088 }
1089
1090 private function mediaMime(string $file): string {
1091 $mime = function_exists('mime_content_type') ? (string)@mime_content_type($file) : '';
1092 if ($mime !== '') {
1093 return $mime;
1094 }
1095
1096 $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
1097 $map = array(
1098 'jpg' => 'image/jpeg',
1099 'jpeg' => 'image/jpeg',
1100 'png' => 'image/png',
1101 'gif' => 'image/gif',
1102 'webp' => 'image/webp',
1103 'svg' => 'image/svg+xml',
1104 );
1105 return $map[$ext] ?? 'application/octet-stream';
1106 }
1107
1108 private function ensureMediaRecordForShopImage(array $image): int {
1109 $mediaId = (int)($image['media_id'] ?? 0);
1110 if ($mediaId > 0) {
1111 return $mediaId;
1112 }
1113
1114 $sourcePath = $this->normalizeShopSourceImagePath((string)($image['image_path'] ?? ''));
1115 if ($sourcePath === '' || stripos($sourcePath, 'dbxmedia:') === 0) {
1116 return 0;
1117 }
1118
1119 $sourceFile = $this->filePathForShopImage($sourcePath);
1120 if ($sourceFile === '' || !is_file($sourceFile) || !is_readable($sourceFile)) {
1121 return 0;
1122 }
1123
1124 $name = basename($sourceFile);
1125 $name = preg_replace('/[^A-Za-z0-9_.-]+/', '-', $name) ?: ('shop-image-' . (int)($image['id'] ?? 0));
1126 $targetDir = $this->shopMediaFolderPath();
1127 if (!is_dir($targetDir)) {
1128 @mkdir($targetDir, 0775, true);
1129 }
1130 $targetFile = rtrim($targetDir, '/\\') . '/' . $name;
1131 if (!is_file($targetFile)) {
1132 @copy($sourceFile, $targetFile);
1133 }
1134 if (!is_file($targetFile)) {
1135 return 0;
1136 }
1137
1138 $rel = 'media/img/shop/' . $name;
1139 $db = $this->db();
1140 $existing = $db->select1('dbxMedia', array('file_path' => $rel), 'id,active', 0);
1141 if (is_array($existing) && (int)($existing['id'] ?? 0) > 0) {
1142 $mediaId = (int)$existing['id'];
1143 if ((int)($existing['active'] ?? 0) !== 1) {
1144 $db->update('dbxMedia', array('active' => 1), $mediaId);
1145 }
1146 $this->repo()->updateImageMediaReference((int)($image['id'] ?? 0), $mediaId, 'dbxmedia:' . $mediaId);
1147 return $mediaId;
1148 }
1149
1150 $width = 0;
1151 $height = 0;
1152 $size = @getimagesize($targetFile);
1153 if (is_array($size)) {
1154 $width = (int)($size[0] ?? 0);
1155 $height = (int)($size[1] ?? 0);
1156 }
1157 $title = trim((string)($image['title'] ?? ''));
1158 if ($title === '') {
1159 $title = pathinfo($name, PATHINFO_FILENAME);
1160 }
1161 $mime = $this->mediaMime($targetFile);
1162 $insert = array(
1163 'active' => 1,
1164 'content_id' => 0,
1165 'folder_id' => 0,
1166 'slot' => 'shop',
1167 'usage' => 'shop',
1168 'sorter' => '',
1169 'template' => '',
1170 'title' => $title,
1171 'alt' => (string)($image['alt'] ?? $title),
1172 'caption' => '',
1173 'file_name' => $name,
1174 'file_path' => $rel,
1175 'mime' => $mime,
1176 'size' => (int)@filesize($targetFile),
1177 'width' => $width,
1178 'height' => $height,
1179 'tags' => 'shop',
1180 'media_type' => 'image',
1181 'storage_type' => 'local',
1182 'media_folder' => 'img/shop',
1183 );
1184 $ok = (int)$db->insert('dbxMedia', $insert);
1185 if ($ok !== 1) {
1186 return 0;
1187 }
1188 $mediaId = (int)$db->get_insert_id();
1189 if ($mediaId <= 0) {
1190 return 0;
1191 }
1192 if ($mediaId > 0) {
1193 $this->repo()->updateImageMediaReference((int)($image['id'] ?? 0), $mediaId, 'dbxmedia:' . $mediaId);
1194 }
1195 return $mediaId;
1196 }
1197
1198 private function migrateExistingShopImagesToMedia(): void {
1199 foreach ($this->repo()->allImages() as $image) {
1200 if ((int)($image['active'] ?? 0) !== 1) {
1201 continue;
1202 }
1203 $this->ensureMediaRecordForShopImage($image);
1204 }
1205 }
1206
1207 private function syncShopMediaUsage(): void {
1208 $db = $this->db();
1209 $contentId = $this->shopMediaUsageContentId();
1210 if ($contentId <= 0) {
1211 return;
1212 }
1213
1214 $slot = $this->shopMediaUsageSlot();
1215 $sourceNeedle = '%"source":"dbxShop"%';
1216 try {
1217 $this->migrateExistingShopImagesToMedia();
1218
1219 // Die Shop-Tabelle ist die einzige Quelle. Alte Snapshots werden
1220 // physisch entfernt, damit jeder Lauf exakt dieselbe Nutzung erzeugt
1221 // und die Datenbank nicht durch inaktive Historie waechst.
1222 $db->delete(
1223 'dbxMediaUsage',
1224 "slot = 'shop' OR settings LIKE '" . str_replace("'", "''", $sourceNeedle) . "'",
1225 1,
1226 0
1227 );
1228
1229 $byMedia = array();
1230 foreach ($this->repo()->allImages() as $image) {
1231 if ((int)($image['active'] ?? 0) !== 1) {
1232 continue;
1233 }
1234 $mediaId = (int)($image['media_id'] ?? 0);
1235 if ($mediaId <= 0) {
1236 continue;
1237 }
1238 if (!isset($byMedia[$mediaId])) {
1239 $byMedia[$mediaId] = array(
1240 'media_id' => $mediaId,
1241 'title' => (string)($image['title'] ?? ''),
1242 'product_ids' => array(),
1243 'group_ids' => array(),
1244 );
1245 }
1246 $productId = (int)($image['product_id'] ?? 0);
1247 $groupId = (int)($image['group_id'] ?? 0);
1248 if ($productId > 0) {
1249 $byMedia[$mediaId]['product_ids'][$productId] = $productId;
1250 }
1251 if ($groupId > 0) {
1252 $byMedia[$mediaId]['group_ids'][$groupId] = $groupId;
1253 }
1254 }
1255
1256 foreach ($byMedia as $mediaId => $info) {
1257 $settings = json_encode(array(
1258 'source' => 'dbxShop',
1259 'product_ids' => array_values($info['product_ids']),
1260 'group_ids' => array_values($info['group_ids']),
1261 ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1262 $db->insert('dbxMediaUsage', array(
1263 'active' => 1,
1264 'media_id' => (int)$mediaId,
1265 'content_id' => $contentId,
1266 'folder_id' => 0,
1267 'content_lng' => $this->shopMediaUsageLng(),
1268 'slot' => $slot,
1269 'sorter' => $this->shopMediaUsageSorter($db, $contentId, $slot),
1270 'template' => 'image-gallery',
1271 'caption' => (string)($info['title'] ?? ''),
1272 'settings' => $settings ?: '{"source":"dbxShop"}',
1273 ));
1274 }
1275 } catch (\Throwable $e) {
1276 if (function_exists('dbx')) {
1277 dbx()->debug('dbxShop media_usage sync failed', $e->getMessage());
1278 }
1279 }
1280 }
1281
1286 private function maintainShopAdminContent(): void {
1287 $this->ensureCmsShopMediaFolder();
1288 $this->ensureShopMediaUsagePage();
1289 $this->ensureShopChannelHelpPage();
1290 $this->ensureShopChannelsHelpPage();
1291 foreach (array('shop', 'amazon', 'ebay', 'kleinanzeigen', 'mobile', 'custom') as $platform) {
1292 $this->ensureShopChannelProviderHelpPage($platform);
1293 }
1294 $this->ensureShopProductGroupsHelpPage();
1295 $this->ensureShopShippingGroupsHelpPage();
1296 $this->ensureShopSettingsHelpPage();
1297 $this->ensureShopOrdersHelpPage();
1298 $this->ensureShopProductsHelpPage();
1299 $this->ensureShopProductChannelMappingHelpPage();
1300 $this->ensureShopProductAttributesHelpPage();
1301 $this->ensureShopMediaHelpPage();
1302 $this->syncShopMediaUsage();
1303 }
1304
1305 private function chips(
1306 array $items,
1307 string $key = 'title',
1308 string $class = '',
1309 string $emptyLabel = 'keine Werte'
1310 ): string {
1311 $html = '';
1312 foreach ($items as $item) {
1313 $value = trim((string)($item[$key] ?? ''));
1314 if ($value === '') continue;
1315 $html .= '<span class="badge text-bg-light border">' . $this->h($value) . '</span>';
1316 }
1317 if ($html === '') {
1318 return '<span class="text-muted small">' . $this->h($emptyLabel) . '</span>';
1319 }
1320 $class = trim('dbx-shop-report-chip-grid ' . $class);
1321 return '<div class="' . $this->h($class) . '">' . $html . '</div>';
1322 }
1323
1324 private function attributeBadges(
1325 array $product,
1326 string $emptyLabel = 'keine Werte'
1327 ): string {
1328 $html = '';
1329 foreach (($product['attributes'] ?? array()) as $attribute) {
1330 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
1331 if ($value === '') continue;
1332 $html .= '<span class="badge text-bg-info text-start text-wrap">' . $this->h($attribute['title'] ?? '') . ': ' . $this->h($value) . '</span>';
1333 }
1334 return $html !== ''
1335 ? '<div class="dbx-shop-report-chip-grid dbx-shop-report-chip-grid-attributes">' . $html . '</div>'
1336 : '<span class="text-muted small">' . $this->h($emptyLabel) . '</span>';
1337 }
1338
1339 private function attributeOptions(string $options): array {
1340 $items = preg_split('~[|;\r\n]+~', $options) ?: array();
1341 return array_values(array_filter(array_map('trim', $items), fn($item) => $item !== ''));
1342 }
1343
1344 private function optionsHtml(array $options, string $selected): string {
1345 $html = '';
1346 foreach ($options as $value => $label) {
1347 $value = (string)$value;
1348 $html .= '<option value="' . $this->h($value) . '"' . ($value === $selected ? ' selected' : '') . '>' . $this->h($label) . '</option>';
1349 }
1350 return $html;
1351 }
1352
1353 private function shopConfig(): array {
1354 $cfg = dbx()->get_config('dbxShop');
1355 return is_array($cfg) ? $cfg : array();
1356 }
1357
1358 private function channelsEnabled(): bool {
1359 $value = strtolower(trim((string)dbx()->get_config('dbxShop', 'channels_enabled')));
1360 return !in_array($value, array('0', 'false', 'off', 'no', 'nein'), true);
1361 }
1362
1363 private function taxRatesConfig(): array {
1364 $cfg = $this->shopConfig();
1365 $rates = $cfg['tax_rates'] ?? array();
1366 if (!is_array($rates) || !count($rates)) {
1367 $rates = array(
1368 'mwst1' => array('title' => 'MwSt. normal', 'rate' => '19'),
1369 'mwst2' => array('title' => 'MwSt. ermaessigt', 'rate' => '7'),
1370 'mwst3' => array('title' => 'MwSt. vorbereitet', 'rate' => '22'),
1371 );
1372 }
1373 foreach (array('mwst1' => 'MwSt. normal', 'mwst2' => 'MwSt. ermaessigt', 'mwst3' => 'MwSt. vorbereitet') as $key => $title) {
1374 if (!isset($rates[$key]) || !is_array($rates[$key])) {
1375 $rates[$key] = array('title' => $title, 'rate' => $key === 'mwst2' ? '7' : ($key === 'mwst3' ? '22' : '19'));
1376 }
1377 }
1378 return $rates;
1379 }
1380
1381 private function taxClassOptions(string $selected): string {
1382 $options = array();
1383 foreach ($this->taxRatesConfig() as $key => $rate) {
1384 if (!is_array($rate)) continue;
1385 $label = trim((string)($rate['title'] ?? $key));
1386 $value = number_format((float)($rate['rate'] ?? 0), 2, ',', '.');
1387 $options[$key] = $label . ' (' . $value . '%)';
1388 }
1389 return $this->optionsHtml($options, $selected !== '' ? $selected : (string)($this->shopConfig()['default_tax_class'] ?? 'mwst1'));
1390 }
1391
1392 private function normalizedText(string $value): string {
1393 $value = strtolower($value);
1394 $value = strtr($value, array('ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'));
1395 $value = preg_replace('~[^a-z0-9]+~', ' ', $value) ?: '';
1396 return preg_replace('~\\s+~', ' ', trim($value)) ?: '';
1397 }
1398
1399 private function searchTerms(string $query): array {
1400 $terms = preg_split('~\\s+~', $this->normalizedText($query)) ?: array();
1401 $stopWords = array_flip(array('der','die','das','den','dem','des','ein','eine','einer','einem','und','oder','mit','ohne','fuer','fur','von','im','in','am','an','auf','zu'));
1402 $out = array();
1403 foreach ($terms as $term) {
1404 $term = trim($term);
1405 if ($term === '' || isset($stopWords[$term])) {
1406 continue;
1407 }
1408 if (strlen($term) < 2 && !ctype_digit($term)) {
1409 continue;
1410 }
1411 $out[$term] = true;
1412 }
1413 return array_keys($out);
1414 }
1415
1416 private function productAttributeText(array $product): string {
1417 $parts = array();
1418 foreach (($product['attributes'] ?? array()) as $attribute) {
1419 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
1420 $parts[] = (string)($attribute['title'] ?? '');
1421 $parts[] = (string)($attribute['attr_key'] ?? '');
1422 if ($value !== '') {
1423 $parts[] = $value;
1424 }
1425 }
1426 return implode(' ', $parts);
1427 }
1428
1429 private function productGroupText(array $product): string {
1430 $parts = array();
1431 foreach (($product['groups'] ?? array()) as $group) {
1432 $parts[] = (string)($group['title'] ?? '');
1433 $parts[] = (string)($group['group_key'] ?? '');
1434 $parts[] = (string)($group['description'] ?? '');
1435 $parts[] = (string)($group['attribute_notes'] ?? '');
1436 }
1437 foreach (($product['shipping_groups'] ?? array()) as $group) {
1438 $parts[] = (string)($group['title'] ?? '');
1439 $parts[] = (string)($group['group_key'] ?? '');
1440 }
1441 foreach (($product['channel_groups'] ?? array()) as $group) {
1442 $parts[] = (string)($group['title'] ?? '');
1443 $parts[] = (string)($group['group_key'] ?? '');
1444 }
1445 foreach (($product['channels'] ?? array()) as $channel) {
1446 $parts[] = (string)($channel['title'] ?? '');
1447 $parts[] = (string)($channel['channel_key'] ?? '');
1448 }
1449 return implode(' ', $parts);
1450 }
1451
1452 private function searchFieldScore(string $text, string $term, int $weight): int {
1453 if ($text === '' || $term === '') {
1454 return 0;
1455 }
1456 if ($text === $term) {
1457 return $weight * 8;
1458 }
1459 $termLength = strlen($term);
1460 $compactText = str_replace(' ', '', $text);
1461 $compactTerm = str_replace(' ', '', $term);
1462 if (strpos($text, $term) !== false || strpos($compactText, $compactTerm) !== false) {
1463 return $weight * 5;
1464 }
1465 $best = 0;
1466 foreach (preg_split('~\\s+~', $text) ?: array() as $token) {
1467 $token = trim($token);
1468 if ($token === '') {
1469 continue;
1470 }
1471 if ($token === $term) {
1472 $best = max($best, $weight * 6);
1473 continue;
1474 }
1475 if ($termLength < 3) {
1476 continue;
1477 }
1478 if (strlen($token) >= $termLength && strpos($token, $term) === 0) {
1479 $best = max($best, $weight * 4);
1480 continue;
1481 }
1482 if (
1483 $termLength >= 4
1484 && strlen($token) >= 4
1485 && substr($token, 0, 3) === substr($term, 0, 3)
1486 && abs(strlen($token) - $termLength) <= ($termLength >= 7 ? 2 : 1)
1487 && levenshtein($token, $term) <= ($termLength >= 7 ? 2 : 1)
1488 ) {
1489 $best = max($best, $weight * 2);
1490 }
1491 }
1492 return $best;
1493 }
1494
1495 private function productSearchScore(array $product, string $query): int {
1496 $terms = $this->searchTerms($query);
1497 if ($terms === array()) {
1498 return 1;
1499 }
1500
1501 $primary = $this->normalizedText(implode(' ', array(
1502 (string)($product['sku'] ?? ''),
1503 (string)($product['title'] ?? ''),
1504 (string)($product['category'] ?? ''),
1505 (string)($product['badge'] ?? ''),
1506 (string)($product['product_type'] ?? ''),
1507 )));
1508 $secondary = $this->normalizedText(implode(' ', array(
1509 (string)($product['summary'] ?? ''),
1510 (string)($product['description'] ?? ''),
1511 )));
1512 $attributes = $this->normalizedText($this->productAttributeText($product));
1513 $groups = $this->normalizedText($this->productGroupText($product));
1514
1515 $score = 0;
1516 $matched = 0;
1517 $firstTermPrimaryScore = 0;
1518 $termCount = count($terms);
1519
1520 foreach ($terms as $idx => $term) {
1521 $primaryScore = $this->searchFieldScore($primary, $term, 10);
1522 $termScore = max(
1523 $primaryScore,
1524 $this->searchFieldScore($attributes, $term, 7),
1525 $this->searchFieldScore($secondary, $term, 4),
1526 $this->searchFieldScore($groups, $term, 3)
1527 );
1528
1529 if ($idx === 0) {
1530 $firstTermPrimaryScore = $primaryScore;
1531 }
1532 if ($termScore > 0) {
1533 $matched++;
1534 $score += $termScore;
1535 }
1536 }
1537
1538 if ($matched === 0) {
1539 return 0;
1540 }
1541 if ($termCount === 1) {
1542 return $score;
1543 }
1544 if ($matched === $termCount || $firstTermPrimaryScore > 0 || $score >= 20) {
1545 return $score + ($matched * 3);
1546 }
1547 return 0;
1548 }
1549
1550 private function productSortValue(array $product, string $sort) {
1551 switch ($sort) {
1552 case 'sku':
1553 case 'title':
1554 return $this->normalizedText((string)($product[$sort] ?? ''));
1555 case 'price_gross':
1556 case 'effective_tax_rate':
1557 case 'effective_shipping_gross':
1558 return (float)($product[$sort] ?? 0);
1559 case 'active':
1560 return (int)($product['active'] ?? 0);
1561 case 'sorter':
1562 default:
1563 return (int)($product['sorter'] ?? 100);
1564 }
1565 }
1566
1567 private function sortProductsForReport(array $products, string $query, string $sort, string $direction): array {
1568 $hasQuery = $this->searchTerms($query) !== array();
1569 $direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
1570 usort($products, function(array $a, array $b) use ($hasQuery, $sort, $direction): int {
1571 if ($hasQuery && (int)($a['_search_score'] ?? 0) !== (int)($b['_search_score'] ?? 0)) {
1572 return (int)($b['_search_score'] ?? 0) <=> (int)($a['_search_score'] ?? 0);
1573 }
1574
1575 $av = $this->productSortValue($a, $sort);
1576 $bv = $this->productSortValue($b, $sort);
1577 if ($av == $bv) {
1578 return strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? ''));
1579 }
1580 $cmp = is_numeric($av) && is_numeric($bv) ? ($av <=> $bv) : strcasecmp((string)$av, (string)$bv);
1581 return $direction === 'DESC' ? -$cmp : $cmp;
1582 });
1583 return $products;
1584 }
1585
1586 public function product_report_next_record($report, $record) {
1587 if (!is_array($record)) {
1588 return $record;
1589 }
1590
1591 $sku = (string)($record['sku'] ?? '');
1592 $productId = (int)($record['id'] ?? 0);
1593 $productTitle = trim((string)($record['title'] ?? $sku));
1594 $summary = trim((string)($record['summary'] ?? ''));
1595 $image = $record['images'][0] ?? array();
1596 $imgUrl = is_array($image) ? $this->mediaItemUrl($image, true) : '';
1597 $emptyLabel = $report->get_fd_message('no_values');
1598
1599 $record['image_view'] = $imgUrl !== ''
1600 ? '<span class="dbx-shop-report-image"><img src="' . $this->h($imgUrl) . '" alt="" loading="lazy"></span>'
1601 : '<span class="text-muted small">' . $this->h($report->get_fd_message('no_image')) . '</span>';
1602 $record['article_view'] = '<div class="dbx-shop-report-article-scroll"><code class="dbx-shop-report-sku">' . $this->h($sku) . '</code>'
1603 . '<br><strong>' . $this->h($productTitle) . '</strong>'
1604 . ($summary !== '' ? '<br><small class="text-muted">' . $this->h($summary) . '</small>' : '')
1605 . '</div>';
1606 $record['groups_view'] = $this->chips($record['groups'] ?? array(), 'title', '', $emptyLabel);
1607 $record['attributes_view'] = $this->attributeBadges($record, $emptyLabel);
1608 $record['shipping_groups_view'] = $this->chips($record['shipping_groups'] ?? array(), 'title', '', $emptyLabel);
1609 $record['channel_groups_view'] = $this->chips($record['channel_groups'] ?? array(), 'title', '', $emptyLabel);
1610 $record['channels_view'] = $this->chips($record['channels'] ?? array(), 'title', 'dbx-shop-report-chip-grid-channels', $emptyLabel);
1611 $record['price_view'] = '<span class="text-nowrap">' . $this->money($record['price_gross'] ?? 0) . '</span>';
1612 $record['tax_view'] = number_format((float)($record['effective_tax_rate'] ?? 0), 2, ',', '.') . '%';
1613 $record['shipping_view'] = '<span class="text-nowrap">' . $this->money($record['effective_shipping_gross'] ?? 0) . '</span>';
1614 $record['status_view'] = ((int)($record['active'] ?? 0) === 1)
1615 ? '<span class="badge text-bg-success">' . $this->h($report->get_fd_message('status_active')) . '</span>'
1616 : '<span class="badge text-bg-secondary">' . $this->h($report->get_fd_message('status_inactive')) . '</span>';
1617
1618 return $record;
1619 }
1620
1621 public function product_report_row_action_data($report, $data) {
1622 if (!is_array($data) || !isset($data['data']) || !is_array($data['data'])) {
1623 return $data;
1624 }
1625
1626 $type = (string)($data['type'] ?? '');
1627 $record = is_array($data['record'] ?? null) ? $data['record'] : array();
1628 $rid = (int)($data['data']['rid'] ?? $record['id'] ?? 0);
1629 $sku = (string)($record['sku'] ?? '');
1630 $title = trim((string)($record['title'] ?? $sku));
1631
1632 if ($type === 'edit') {
1633 $url = '?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $rid;
1634 $data['data']['action'] = $url;
1635 $data['data']['class'] = 'openWin dbx-win';
1636 $data['data']['tooltip'] = $report->format_fd_message(
1637 'action_edit',
1638 array('title' => $title)
1639 );
1640 } elseif ($type === 'show') {
1641 $url = '?dbx_modul=dbxShop&dbx_run1=product&sku=' . rawurlencode($sku);
1642 $data['data']['action'] = $url;
1643 $data['data']['class'] = 'openWin dbx-win';
1644 $data['data']['tooltip'] = $report->format_fd_message(
1645 'action_view',
1646 array('title' => $title)
1647 );
1648 } elseif ($type === 'delete') {
1649 $data['data']['action'] = '?dbx_modul=dbxShop_admin&dbx_run1=products';
1650 $data['data']['confirm'] = $report->format_fd_message(
1651 'action_delete_confirm',
1652 array('title' => $title)
1653 );
1654 }
1655
1656 return $data;
1657 }
1658
1659 private function productTreePanel(array $products, $texts): string {
1660 $groups = $this->repo()->groups();
1661 $groupsByParent = array();
1662 foreach ($groups as $group) {
1663 $parentId = (int)($group['parent_id'] ?? 0);
1664 $groupsByParent[$parentId][] = $group;
1665 }
1666
1667 $productsByGroup = array();
1668 foreach ($products as $product) {
1669 $groupId = (int)($product['product_group_id'] ?? 0);
1670 if ($groupId <= 0 && isset($product['groups'][0])) {
1671 $groupId = (int)($product['groups'][0]['id'] ?? 0);
1672 }
1673 $productsByGroup[$groupId][] = $product;
1674 }
1675
1676 $renderProducts = function(int $groupId, bool $asListItem = false) use (&$productsByGroup, $texts): string {
1677 $items = '';
1678 foreach (($productsByGroup[$groupId] ?? array()) as $product) {
1679 $id = (int)($product['id'] ?? 0);
1680 $title = trim((string)($product['title'] ?? $texts->get_fd_message('tree_product_fallback')));
1681 $sku = trim((string)($product['sku'] ?? ''));
1682 if ($id <= 0) continue;
1683 $searchText = trim($title . ' ' . $sku);
1684 $items .= '<li class="dbx-shop-tree-product" draggable="true" data-shop-tree-node="product" data-shop-tree-product="' . $id . '" data-shop-tree-search-text="' . $this->h($searchText) . '">';
1685 $items .= '<span class="dbx-shop-tree-product-main"><i class="bi bi-box-seam"></i><span><strong>' . $this->h($title) . '</strong>' . ($sku !== '' ? '<small>' . $this->h($sku) . '</small>' : '') . '</span></span>';
1686 $items .= '<a class="btn btn-outline-primary btn-sm openWin dbx-win" href="?dbx_modul=dbxShop_admin&amp;dbx_run1=product_edit&amp;id=' . $id . '" title="' . $this->h($texts->get_fd_message('tree_edit_product')) . '"><i class="bi bi-pencil"></i></a>';
1687 $items .= '</li>';
1688 }
1689 if ($items === '') {
1690 return '';
1691 }
1692 $html = '<ul class="dbx-shop-tree-products">' . $items . '</ul>';
1693 return $asListItem ? '<li class="dbx-shop-tree-product-list">' . $html . '</li>' : $html;
1694 };
1695
1696 $renderGroup = function(array $group) use (&$renderGroup, &$groupsByParent, $renderProducts, $texts): string {
1697 $id = (int)($group['id'] ?? 0);
1698 if ($id <= 0) return '';
1699 $title = trim((string)($group['title'] ?? $texts->get_fd_message('tree_group_fallback')));
1700 $childHtml = '';
1701 foreach (($groupsByParent[$id] ?? array()) as $child) {
1702 $childHtml .= $renderGroup($child);
1703 }
1704 $productsHtml = $renderProducts($id, true);
1705 $countChildren = count($groupsByParent[$id] ?? array());
1706 $countProducts = substr_count($productsHtml, 'data-shop-tree-node="product"');
1707 $hasChildren = $childHtml !== '' || $productsHtml !== '';
1708 $html = '<li class="dbx-shop-tree-group" data-shop-tree-group-wrap data-shop-tree-search-text="' . $this->h($title) . '">';
1709 $html .= '<div class="dbx-shop-tree-group-head" draggable="true" data-shop-tree-node="group" data-shop-tree-group="' . $id . '" data-shop-tree-drop="' . $id . '">';
1710 $html .= '<span class="dbx-shop-tree-group-main">';
1711 if ($hasChildren) {
1712 $html .= '<button type="button" class="dbx-shop-tree-group-toggle" data-shop-tree-group-toggle title="' . $this->h($texts->get_fd_message('tree_toggle_group')) . '" aria-label="' . $this->h($texts->get_fd_message('tree_toggle_group')) . '" aria-expanded="true"><i class="bi bi-chevron-down"></i></button>';
1713 } else {
1714 $html .= '<span class="dbx-shop-tree-toggle-spacer"></span>';
1715 }
1716 $html .= '<i class="bi bi-folder2"></i><span><strong>' . $this->h($title) . '</strong><small>' . $this->h($texts->format_fd_message('tree_counts', array('groups' => $countChildren, 'products' => $countProducts))) . '</small></span></span>';
1717 $html .= '<a class="btn btn-outline-secondary btn-sm openWin dbx-win" href="?dbx_modul=dbxShop_admin&amp;dbx_run1=groups" data-url="?dbx_modul=dbxShop_admin&amp;dbx_run1=groups" data-title="' . $this->h($texts->get_fd_message('tree_edit_groups')) . '" data-width="54%" data-height="84%" title="' . $this->h($texts->get_fd_message('tree_edit_groups')) . '"><i class="bi bi-diagram-3"></i></a>';
1718 $html .= '</div>';
1719 if ($hasChildren) {
1720 $html .= '<ul class="dbx-shop-tree-children">' . $childHtml . $productsHtml . '</ul>';
1721 }
1722 $html .= '</li>';
1723 return $html;
1724 };
1725
1726 $rootGroups = '';
1727 foreach (($groupsByParent[0] ?? array()) as $group) {
1728 $rootGroups .= $renderGroup($group);
1729 }
1730 $ungrouped = $renderProducts(0, false);
1731 $search = $this->tpl()->get_tpl('dbx|search', dbx()->search_defaults(array(
1732 'name' => 'shop_tree_search',
1733 'placeholder' => $texts->get_fd_message('tree_search_placeholder'),
1734 'title' => $texts->get_fd_message('tree_search_title'),
1735 'wrap_class' => 'dbx-shop-tree-search-wrap',
1736 'extra_attrs' => 'data-shop-tree-search',
1737 'i' => 1,
1738 )));
1739 $treeMoveUrl = str_replace('&', '&amp;', $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=product_tree_move'));
1740 $html = '<section class="dbx-shop-product-tree-panel" data-shop-tree-panel data-shop-tree-moveurl="' . $treeMoveUrl . '" aria-label="' . $this->h($texts->get_fd_message('tree_aria')) . '">';
1741 $html .= '<div class="dbx-shop-product-tree-head"><div><h3>' . $this->h($texts->get_fd_message('tree_title')) . '</h3><p>' . $this->h($texts->get_fd_message('tree_subtitle')) . '</p></div><div class="dbx-shop-product-tree-actions"><a class="btn btn-outline-primary btn-sm openWin dbx-win" href="?dbx_modul=dbxShop_admin&amp;dbx_run1=groups" data-url="?dbx_modul=dbxShop_admin&amp;dbx_run1=groups" data-title="' . $this->h($texts->get_fd_message('tree_edit_groups')) . '" data-width="54%" data-height="84%"><i class="bi bi-diagram-3"></i> ' . $this->h($texts->get_fd_message('tree_edit_groups_button')) . '</a><button type="button" class="btn btn-outline-secondary btn-sm" data-shop-tree-close title="' . $this->h($texts->get_fd_message('tree_close')) . '" aria-label="' . $this->h($texts->get_fd_message('tree_close')) . '"><i class="bi bi-x-lg"></i></button></div></div>';
1742 $html .= '<div class="dbx-shop-tree-tools">' . $search . '</div>';
1743 $html .= '<ul class="dbx-shop-tree-list">' . $rootGroups . '</ul>';
1744 if ($ungrouped !== '') {
1745 $html .= '<div class="dbx-shop-tree-ungrouped"><strong>' . $this->h($texts->get_fd_message('tree_ungrouped')) . '</strong>' . $ungrouped . '</div>';
1746 }
1747 $html .= '</section>';
1748 return $html;
1749 }
1750
1751 private function productTreeToggleButton($texts): string {
1752 $label = $this->h($texts->get_fd_message('tree_open'));
1753 return '<button type="button" class="btn btn-outline-secondary btn-sm dbx-shop-product-tree-toggle" data-dbx="lib=shopAdmin" data-shop-tree-toggle title="' . $label . '" aria-label="' . $label . '" aria-expanded="false"><i class="bi bi-diagram-3"></i></button>';
1754 }
1755
1756 private function selectedProductIds($report): array {
1757 $ids = array();
1758 foreach (array_keys($report->get_multi_selects()) as $id) {
1759 $id = (int)$id;
1760 if ($id > 0) {
1761 $ids[$id] = $id;
1762 }
1763 }
1764 return array_values($ids);
1765 }
1766
1767 private function productReportActionControls(string $baseAction, $texts): string {
1768 $channels = '<option value="">' . $this->h($texts->get_fd_message('bulk_channel_placeholder')) . '</option>';
1769 foreach ($this->repo()->channels() as $channel) {
1770 $key = (string)($channel['channel_key'] ?? '');
1771 if ($key === '') {
1772 continue;
1773 }
1774 $channels .= '<option value="' . $this->h($key) . '">' . $this->h($channel['title'] ?? $key) . '</option>';
1775 }
1776
1777 $groups = '<option value="0">' . $this->h($texts->get_fd_message('bulk_group_placeholder')) . '</option>';
1778 foreach ($this->repo()->groups() as $group) {
1779 $id = (int)($group['id'] ?? 0);
1780 if ($id <= 0) {
1781 continue;
1782 }
1783 $groups .= '<option value="' . $id . '">' . $this->h($group['title'] ?? '') . '</option>';
1784 }
1785
1786 $url = function(string $do, array $params = array()) use ($baseAction): string {
1787 $query = $baseAction . '&dbx_do=' . rawurlencode($do);
1788 foreach ($params as $key => $value) {
1789 $query .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
1790 }
1791 return $this->h($query);
1792 };
1793
1794 $actions = '<option value="">' . $this->h($texts->get_fd_message('bulk_action_placeholder')) . '</option>'
1795 . '<option value="shop_products_delete">' . $this->h($texts->get_fd_message('bulk_delete')) . '</option>'
1796 . '<option value="shop_products_channel_add">' . $this->h($texts->get_fd_message('bulk_channel_add')) . '</option>'
1797 . '<option value="shop_products_channel_remove">' . $this->h($texts->get_fd_message('bulk_channel_remove')) . '</option>'
1798 . '<option value="shop_products_channel_export">' . $this->h($texts->get_fd_message('bulk_channel_export')) . '</option>'
1799 . '<option value="shop_products_group_set">' . $this->h($texts->get_fd_message('bulk_group_set')) . '</option>';
1800
1801 return '<div class="dbx-shop-products-bulk-actions">'
1802 . '<select class="form-select form-select-sm" name="dbx_products_bulk_action" title="' . $this->h($texts->get_fd_message('bulk_title')) . '">' . $actions . '</select>'
1803 . '<select class="form-select form-select-sm" name="dbx_action_channel" title="' . $this->h($texts->get_fd_message('bulk_channel_title')) . '">' . $channels . '</select>'
1804 . '<select class="form-select form-select-sm" name="dbx_action_group" title="' . $this->h($texts->get_fd_message('bulk_group_title')) . '">' . $groups . '</select>'
1805 . '<a class="btn btn-primary btn-sm dbxAjaxFormAction dbxConfirm" href="' . $url('shop_products_apply') . '" data-confirm-title="<i class=\'bi bi-lightning-fill\'></i> ' . $this->h($texts->get_fd_message('bulk_confirm_title')) . '" data-confirm="' . $this->h($texts->get_fd_message('bulk_confirm_question')) . '" data-confirm-hint="<small>' . $this->h($texts->get_fd_message('bulk_confirm_hint')) . '</small>" data-confirm-buttons="yesno" role="button"><i class="bi bi-check2-circle"></i> ' . $this->h($texts->get_fd_message('bulk_execute')) . '</a>'
1806 . '</div>';
1807 }
1808
1809 private function handleProductReportAction($report): void {
1810 $do = (string)dbx()->get_modul_var('dbx_do', '', 'parameter');
1811 $mutatingActions = array(
1812 'row_delete',
1813 'shop_products_apply',
1814 'shop_products_delete',
1815 'shop_products_channel_add',
1816 'shop_products_channel_remove',
1817 'shop_products_channel_export',
1818 'shop_products_group_set',
1819 );
1820 // row_delete ist bereits als dbxReport-Standardaktion zentral geprueft.
1821 // Die Shop-spezifischen Sammelaktionen behalten ihren fachlichen Scope.
1822 if ($do !== 'row_delete'
1823 && in_array($do, $mutatingActions, true)
1824 && !$this->checkActionToken('product_report_action')) {
1825 $report->_msg_error = $report->get_fd_message('token_error');
1826 return;
1827 }
1828
1829 $rid = (int)dbx()->get_modul_var('rid', 0, 'int');
1830 if ($do === 'row_delete') {
1831 if ($rid <= 0) {
1832 $report->_msg_error = $report->get_fd_message('product_delete_error');
1833 return;
1834 }
1835 $count = $this->repo()->deleteProducts(array($rid));
1836 $report->del_multi_select($rid);
1837 $report->_msg_success = $count === 1
1838 ? $report->get_fd_message('product_delete_success')
1839 : '';
1840 $report->_msg_error = $count === 1
1841 ? ''
1842 : $report->get_fd_message('product_delete_error');
1843 return;
1844 }
1845
1846 if ($do === 'shop_products_apply') {
1847 $do = (string)dbx()->get_modul_var('dbx_products_bulk_action', '', 'parameter');
1848 }
1849
1850 if (!in_array($do, array('shop_products_delete', 'shop_products_channel_add', 'shop_products_channel_remove', 'shop_products_channel_export', 'shop_products_group_set'), true)) {
1851 if ((string)dbx()->get_modul_var('dbx_do', '', 'parameter') === 'shop_products_apply') {
1852 $report->_msg_error = $report->get_fd_message('choose_action');
1853 }
1854 return;
1855 }
1856
1857 $ids = $this->selectedProductIds($report);
1858 if ($ids === array()) {
1859 $report->_msg_error = $report->get_fd_message('select_products');
1860 return;
1861 }
1862
1863 if ($do === 'shop_products_delete') {
1864 $count = $this->repo()->deleteProducts($ids);
1865 foreach ($ids as $id) {
1866 $report->del_multi_select($id);
1867 }
1868 $report->_msg_success = $count === 1
1869 ? $report->get_fd_message('multi_deleted_one')
1870 : $report->format_fd_message(
1871 'multi_deleted_many',
1872 array('count' => $count)
1873 );
1874 return;
1875 }
1876
1877 if ($do === 'shop_products_channel_add') {
1878 $channel = trim((string)dbx()->get_modul_var('dbx_action_channel', '', 'parameter'));
1879 if ($channel === '') {
1880 $report->_msg_error = $report->get_fd_message('choose_channel');
1881 return;
1882 }
1883 $count = $this->repo()->addChannelToProducts($ids, $channel);
1884 $report->_msg_success = $report->format_fd_message(
1885 'channel_added',
1886 array('count' => $count, 'channel' => $channel)
1887 );
1888 return;
1889 }
1890
1891 if ($do === 'shop_products_channel_remove') {
1892 $channel = trim((string)dbx()->get_modul_var('dbx_action_channel', '', 'parameter'));
1893 if ($channel === '') {
1894 $report->_msg_error = $report->get_fd_message('choose_channel');
1895 return;
1896 }
1897 $count = $this->repo()->removeChannelFromProducts($ids, $channel);
1898 $report->_msg_success = $report->format_fd_message(
1899 'channel_removed',
1900 array('count' => $count, 'channel' => $channel)
1901 );
1902 return;
1903 }
1904
1905 if ($do === 'shop_products_channel_export') {
1906 $channel = trim((string)dbx()->get_modul_var('dbx_action_channel', '', 'parameter'));
1907 if ($channel === '') {
1908 $report->_msg_error = $report->get_fd_message('choose_channel');
1909 return;
1910 }
1911 $summary = $this->repo()->exportProductsToChannel($ids, $channel);
1912 $report->_msg_success = $report->format_fd_message(
1913 'export_summary',
1914 array(
1915 'ok' => (int)($summary['ok'] ?? 0),
1916 'failed' => (int)($summary['failed'] ?? 0),
1917 )
1918 );
1919 if (!empty($summary['messages'])) {
1920 $report->_msg_info = implode('<br>', array_map(fn($msg) => $this->h($msg), array_slice((array)$summary['messages'], 0, 8)));
1921 }
1922 return;
1923 }
1924
1925 if ($do === 'shop_products_group_set') {
1926 $groupId = (int)dbx()->get_modul_var('dbx_action_group', 0, 'int');
1927 if ($groupId <= 0) {
1928 $report->_msg_error = $report->get_fd_message('choose_group');
1929 return;
1930 }
1931 $count = $this->repo()->setProductGroupForProducts($ids, $groupId);
1932 $report->_msg_success = $report->format_fd_message(
1933 'group_set',
1934 array('count' => $count)
1935 );
1936 }
1937 }
1938
1939 private function cardTemplateOptions(string $selected): string {
1940 return $this->optionsHtml(array(
1941 'product-card-default' => 'Standardkarte',
1942 'product-card-compact' => 'Kompaktkarte',
1943 ), $selected);
1944 }
1945
1946 private function detailTemplateOptions(string $selected): string {
1947 return $this->optionsHtml(array(
1948 'product-detail-default' => 'Standarddetail',
1949 'product-detail-technical' => 'Technische Ansicht',
1950 ), $selected);
1951 }
1952
1953 private function galleryTemplateOptions(string $selected): string {
1954 return $this->optionsHtml(array(
1955 'image-gallery' => 'Bild Gallery',
1956 'file-gallery' => 'Datei Gallery',
1957 ), $selected);
1958 }
1959
1960 private function galleryOverflowOptions(string $selected): string {
1961 return $this->optionsHtml(array(
1962 'grid' => 'Grid',
1963 'slider' => 'Slider',
1964 'scroll' => 'Scroll',
1965 'laufband' => 'Laufband',
1966 'tutorial' => 'Tutorial',
1967 ), $selected);
1968 }
1969
1970 private function galleryClickOptions(string $selected): string {
1971 return $this->optionsHtml(array(
1972 'lightbox' => 'Lightbox',
1973 'none' => 'Kein Klick',
1974 'newtab' => 'Neuer Tab',
1975 'viewerjs' => 'ViewerJS',
1976 'photoswipe' => 'PhotoSwipe',
1977 ), $selected);
1978 }
1979
1980 private function shopAdminStyle(): string {
1981 $file = dirname(__DIR__) . '/design/css/shop-admin.css';
1982 if (!is_file($file)) {
1983 return '';
1984 }
1985 return '<style>' . file_get_contents($file) . '</style>';
1986 }
1987
1988 private function frame(string $content, string $title = 'Shop Administration', string $barActions = ''): string {
1989 if ($this->postedFormError !== '') {
1990 $content = '<div class="alert alert-danger mx-3 mt-3 mb-0" role="alert">'
1991 . $this->h($this->postedFormError)
1992 . '</div>'
1993 . $content;
1994 $this->postedFormError = '';
1995 }
1996
1997 return $this->tpl()->get_tpl('dbxShop_admin|admin-shell', array(
1998 'shop_admin_style' => $this->shopAdminStyle(),
1999 'bar_title' => $this->h($title),
2000 'bar_icon' => 'bi-bag-check',
2001 'bar_subtitle' => $this->h($this->catalogTexts()->get_fd_message('admin_subtitle')),
2002 'bar_title_pre' => '',
2003 'bar_title_heading_attrs' => '',
2004 'bar_middle' => '',
2005 'bar_extra' => '',
2006 'bar_actions' => $barActions,
2007 'content' => $content,
2008 ));
2009 }
2010
2011 private function productBarActions($texts = null): string {
2012 $title = $texts && method_exists($texts, 'get_fd_message')
2013 ? $texts->get_fd_message('new_product_title', 'Neuen Artikel anlegen')
2014 : 'Neuen Artikel anlegen';
2015 $label = $texts && method_exists($texts, 'get_fd_message')
2016 ? $texts->get_fd_message('new_product', 'Neuer Artikel')
2017 : 'Neuer Artikel';
2018 $help = $texts && method_exists($texts, 'get_fd_message')
2019 ? $texts->get_fd_message('products_help', 'Hilfe: Produkte')
2020 : 'Hilfe: Produkte';
2021
2022 return '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=0" title="' . $this->h($title) . '"><i class="bi bi-plus-square"></i><span class="visually-hidden"> ' . $this->h($label) . '</span></a>'
2023 . $this->helpButton($this->ensureShopProductsHelpPage(), $help);
2024 }
2025
2026 private function productShellActions($texts = null): string {
2027 return $this->productBarActions($texts);
2028 }
2029
2030 private function productFormDefaults(int $id): array {
2031 $now = date('Y-m-d H:i:s');
2032 $uid = (int)dbx()->user();
2033 $defaults = array(
2034 'update_date' => $now,
2035 'update_uid' => $uid,
2036 );
2037 if ($id <= 0) {
2038 $defaults['create_date'] = $now;
2039 $defaults['create_uid'] = $uid;
2040 $defaults['owner'] = $uid;
2041 $defaults['trash'] = 0;
2042 $defaults['currency'] = 'EUR';
2043 }
2044 return $defaults;
2045 }
2046
2047 private function newProductDefaults(): array {
2048 return array(
2049 'sku' => '',
2050 'slug' => '',
2051 'title' => '',
2052 'category' => 'Merchandise',
2053 'product_type' => 'physical',
2054 'summary' => '',
2055 'description' => '',
2056 'price_gross' => '0.00',
2057 'currency' => 'EUR',
2058 'tax_mode' => 'group',
2059 'tax_rate' => '-1',
2060 'shipping_mode' => 'group',
2061 'shipping_gross' => '-1',
2062 'stock' => '0',
2063 'active' => 1,
2064 'sorter' => 100,
2065 'badge' => '',
2066 'image_icon' => 'bi-box-seam',
2067 'logo_variant' => '',
2068 );
2069 }
2070
2071 private function applyProductPreset(array $data): array {
2072 $preset = trim((string)dbx()->get_modul_var('workflow_preset', '', 'parameter'));
2073 if ($preset === 'shop_article_publish') {
2074 $data['category'] = $data['category'] ?: 'Merchandise';
2075 $data['product_type'] = $data['product_type'] ?: 'physical';
2076 $data['active'] = 1;
2077 }
2078
2079 $map = array(
2080 'preset_sku' => 'sku',
2081 'preset_slug' => 'slug',
2082 'preset_title' => 'title',
2083 'preset_category' => 'category',
2084 'preset_product_type' => 'product_type',
2085 'preset_summary' => 'summary',
2086 'preset_price_gross' => 'price_gross',
2087 'preset_group_id' => 'product_group_id',
2088 );
2089 foreach ($map as $param => $field) {
2090 $value = trim((string)dbx()->get_modul_var($param, '', '*'));
2091 if ($value !== '') {
2092 $data[$field] = $value;
2093 }
2094 }
2095
2096 return $data;
2097 }
2098
2099 private function productFormActions(int $id, $texts): string {
2100 $html = $this->helpButton(
2101 $this->ensureShopProductsHelpPage(),
2102 $texts->get_fd_message('help_edit'),
2103 'btn btn-outline-secondary btn-sm ms-1'
2104 )
2105 . '<button class="btn btn-primary btn-sm" type="submit" title="' . $this->h($texts->get_fd_message('save_title')) . '"><i class="bi bi-save"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('save_label')) . '</span></button>';
2106
2107 if ($id > 0) {
2108 $product = $this->repo()->productById($id);
2109 $previewUrl = '?dbx_modul=dbxShop&dbx_run1=product&sku=' . rawurlencode((string)($product['sku'] ?? ''));
2110 $deleteUrl = $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=products&dbx_do=row_delete&rid=' . $id);
2111 $html .= $this->openWinButton(
2112 $previewUrl,
2113 $texts->get_fd_message('view_product'),
2114 '<i class="bi bi-search"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('preview')) . '</span>',
2115 'btn btn-outline-primary btn-sm ms-1',
2116 '82%',
2117 '82%'
2118 );
2119 $html .= $this->openWinButton(
2120 '?dbx_modul=dbxShop_admin&dbx_run1=product_attributes&id=' . $id,
2121 $texts->get_fd_message('product_attributes'),
2122 '<i class="bi bi-sliders"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('attributes')) . '</span>',
2123 'btn btn-outline-primary btn-sm ms-1',
2124 '76%',
2125 '78%'
2126 );
2127 $html .= '<a class="btn btn-outline-danger btn-sm ms-1 dbxConfirm" href="' . $this->h($deleteUrl) . '" data-confirm-title="' . $this->h($texts->get_fd_message('delete_title')) . '" data-confirm="' . $this->h($texts->get_fd_message('delete_confirm')) . '" data-confirm-buttons="yesno" title="' . $this->h($texts->get_fd_message('delete_title')) . '"><i class="bi bi-trash"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('delete_label')) . '</span></a>';
2128 }
2129
2130 $html .= '<a class="btn btn-outline-secondary btn-sm ms-1" href="?dbx_modul=dbxShop_admin&dbx_run1=products" title="' . $this->h($texts->get_fd_message('product_list_title')) . '"><i class="bi bi-table"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('product_list')) . '</span></a>';
2131
2132 return $html;
2133 }
2134
2135 private function productGroupOptions(int $excludeId = 0, bool $withNone = false, $texts = null): array {
2136 $groups = $this->repo()->groups();
2137 $byParent = array();
2138 foreach ($groups as $group) {
2139 $parentId = (int)($group['parent_id'] ?? 0);
2140 $byParent[$parentId][] = $group;
2141 }
2142
2143 $texts = $texts ?: $this->catalogTexts();
2144 $options = $withNone ? array('0' => $texts->get_fd_message('groups_no_parent')) : array();
2145 $walk = function(int $parentId, string $prefix) use (&$walk, &$options, $byParent, $excludeId): void {
2146 foreach (($byParent[$parentId] ?? array()) as $group) {
2147 $id = (int)($group['id'] ?? 0);
2148 if ($id <= 0 || $id === $excludeId) {
2149 continue;
2150 }
2151 $title = trim((string)($group['title'] ?? ''));
2152 if ($title === '') {
2153 $title = (string)($group['group_key'] ?? $id);
2154 }
2155 $label = $prefix !== '' ? $prefix . ' / ' . $title : $title;
2156 $options[(string)$id] = $label;
2157 $walk($id, $label);
2158 }
2159 };
2160 $walk(0, '');
2161 return $options;
2162 }
2163
2164 private function shopMediaConfig(): array {
2165 return array(
2166 'media' => $this->cmsEndpoint('cms_media', array('images' => 1, 'media_type' => 'image'), true),
2167 'uploadmediafolder' => 'img/shop',
2168 'upload' => $this->cmsEndpoint('cms_upload', array(), true),
2169 'externalvideo' => $this->cmsEndpoint('cms_external_video', array(), true),
2170 'mediafolders' => $this->cmsEndpoint('cms_media_folders'),
2171 'mediafoldercreate' => $this->cmsEndpoint('cms_media_folder_create', array(), true),
2172 'mediafolderdelete' => $this->cmsEndpoint('cms_media_folder_delete', array(), true),
2173 'mediafolderrename' => $this->cmsEndpoint('cms_media_folder_rename', array(), true),
2174 'mediamove' => $this->cmsEndpoint('cms_media_move', array(), true),
2175 'mediaunused' => $this->cmsEndpoint('cms_media_unused'),
2176 'mediaprocess' => $this->cmsEndpoint('cms_media_process', array(), true),
2177 'deletemedia' => $this->cmsEndpoint('cms_delete_media', array(), true),
2178 'editmedia' => $this->cmsEndpoint('cms_edit_media', array(), true),
2179 'assignurl' => $this->shopEndpoint('assign_media', array(), true),
2180 );
2181 }
2182
2183 private function shopMediaAttrs(array $mediaCfg): string {
2184 $attrs = ' data-dbx="lib=shopAdmin"';
2185 foreach ($mediaCfg as $key => $value) {
2186 $attrs .= ' data-shop-' . $this->h($key) . '="' . $this->h($value) . '"';
2187 }
2188 return $attrs;
2189 }
2190
2200 private function shopMediaFormTemplates(array $mediaCfg): string {
2201 return dbx()->get_include_obj('dbxContentMediaForms', 'dbxContent_admin')->renderTemplates(
2202 (string)($mediaCfg['upload'] ?? ''),
2203 'cms-media-upload',
2204 (string)($mediaCfg['externalvideo'] ?? ''),
2205 'cms-external-video'
2206 );
2207 }
2208
2209 private function productImagesPanel(array $product, bool $isNew, $texts): string {
2210 if ($isNew) {
2211 return '<aside class="border rounded bg-light p-3"><h6 class="mb-3">'
2212 . $this->h($texts->get_fd_message('images_title'))
2213 . '</h6><div class="alert alert-info mb-0">'
2214 . $this->h($texts->get_fd_message('save_first_images'))
2215 . '</div></aside>';
2216 }
2217
2218 $productId = (int)($product['id'] ?? 0);
2219 $mediaCfg = $this->shopMediaConfig();
2220 $html = '<aside class="border rounded bg-light p-3 dbx-shop-media-manager dbx-shop-product-image-panel"' . $this->shopMediaAttrs($mediaCfg) . '>';
2221 $html .= '<input type="hidden" value="' . $productId . '" data-shop-product-select>';
2222 $html .= '<input type="hidden" value="0" data-shop-group-select>';
2223 $html .= '<input type="hidden" value="100" data-shop-sorter>';
2224 $html .= '<div class="d-flex justify-content-between align-items-center gap-2 mb-3">';
2225 $html .= '<h6 class="mb-0">' . $this->h($texts->get_fd_message('images_title')) . '</h6>';
2226 $html .= '<button type="button" class="btn btn-outline-primary btn-sm dbx-shop-media-pick" data-shop-media-folder="img/shop" title="' . $this->h($texts->get_fd_message('select_images_title')) . '"><i class="bi bi-images"></i><i class="bi bi-camera-video"></i><i class="bi bi-upload"></i><span>' . $this->h($texts->get_fd_message('selection')) . '</span></button>';
2227 $html .= '</div>';
2228 $html .= '<label class="form-check mb-3"><input class="form-check-input" type="checkbox" value="1" data-shop-primary> <span class="form-check-label">' . $this->h($texts->get_fd_message('new_primary')) . '</span></label>';
2229
2230 $images = (array)($product['images'] ?? array());
2231 if ($images === array()) {
2232 $html .= '<div class="text-muted small">' . $this->h($texts->get_fd_message('no_images')) . '</div>';
2233 } else {
2234 $html .= '<div class="dbx-shop-image-list">';
2235 foreach ($images as $image) {
2236 $imageId = (int)($image['id'] ?? 0);
2237 $source = (int)($image['product_id'] ?? 0) === $productId
2238 ? $texts->get_fd_message('image_source_product')
2239 : $texts->get_fd_message('image_source_group');
2240 $primary = (int)($image['is_primary'] ?? 0) === 1
2241 ? '<span class="badge text-bg-primary ms-1">' . $this->h($texts->get_fd_message('primary')) . '</span>'
2242 : '';
2243 $title = trim((string)($image['title'] ?? ''));
2244 if ($title === '') {
2245 $title = basename((string)($image['image_path'] ?? 'Bild'));
2246 }
2247 $removeUrl = $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $productId . '&remove_image=' . $imageId);
2248 $html .= '<figure class="dbx-shop-image-card">';
2249 if ($imageId > 0) {
2250 $html .= '<a class="btn btn-outline-danger btn-sm dbxAjax dbxConfirm dbx-shop-image-unassign" href="' . $this->h($removeUrl) . '" data-confirm-title="<i class=\'bi bi-trash\'></i> ' . $this->h($texts->get_fd_message('unlink_image_title')) . '" data-confirm="' . $this->h($texts->get_fd_message('unlink_image_question')) . '" data-confirm-hint="<small>' . $this->h($texts->get_fd_message('unlink_image_hint')) . '</small>" data-confirm-buttons="yesno" title="' . $this->h($texts->get_fd_message('unlink_image_title')) . '"><i class="bi bi-trash"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('unlink_image_label')) . '</span></a>';
2251 }
2252 $html .= '<img src="' . $this->h($this->mediaItemUrl($image, true)) . '" alt="' . $this->h($image['alt'] ?? $title) . '">';
2253 $html .= '<figcaption><strong>' . $this->h($title) . '</strong><br><span class="text-muted">' . $this->h($source) . '</span>' . $primary . '</figcaption>';
2254 $html .= '</figure>';
2255 }
2256 $html .= '</div>';
2257 }
2258
2259 $html .= '<div class="form-text mt-3">' . $this->h($texts->get_fd_message('media_hint')) . '</div>';
2260 $html .= '</aside>';
2261 return $html;
2262 }
2263
2264 private function productGroupImagePanel(array $group, bool $isNew, $texts = null): string {
2265 $texts = $texts ?: $this->catalogTexts();
2266 if ($isNew) {
2267 return '<div class="dbx-shop-group-image-panel dbx-shop-group-image-empty"><div class="form-text">' . $this->h($texts->get_fd_message('group_image_save_first')) . '</div></div>';
2268 }
2269
2270 $groupId = (int)($group['id'] ?? 0);
2271 if ($groupId <= 0) {
2272 return '';
2273 }
2274
2275 $mediaCfg = $this->shopMediaConfig();
2276 $image = $this->repo()->primaryImageForGroup($groupId);
2277 $html = '<div class="dbx-shop-media-manager dbx-shop-group-image-panel"' . $this->shopMediaAttrs($mediaCfg) . '>';
2278 $html .= '<input type="hidden" value="0" data-shop-product-select>';
2279 $html .= '<input type="hidden" value="' . $groupId . '" data-shop-group-select>';
2280 $html .= '<input type="hidden" value="10" data-shop-sorter>';
2281 $html .= '<input type="hidden" value="1" data-shop-primary>';
2282 $html .= '<div class="dbx-shop-group-image-head">';
2283 $html .= '<span>' . $this->h($texts->get_fd_message('group_image_title')) . '</span>';
2284 $html .= '<button type="button" class="btn btn-outline-primary btn-sm dbx-shop-media-pick" data-shop-media-folder="img/shop" title="' . $this->h($texts->get_fd_message('group_image_select_title')) . '"><i class="bi bi-images"></i><span>' . $this->h($texts->get_fd_message('selection')) . '</span></button>';
2285 $html .= '</div>';
2286 if (is_array($image)) {
2287 $title = trim((string)($image['title'] ?? ''));
2288 if ($title === '') {
2289 $title = basename((string)($image['image_path'] ?? 'Gruppenbild'));
2290 }
2291 $html .= '<figure class="dbx-shop-group-image-preview"><img src="' . $this->h($this->mediaItemUrl($image, true)) . '" alt="' . $this->h($image['alt'] ?? $title) . '"><figcaption>' . $this->h($title) . '</figcaption></figure>';
2292 } else {
2293 $html .= '<div class="dbx-shop-group-image-placeholder"><i class="bi bi-image"></i><span>' . $this->h($texts->get_fd_message('group_image_none')) . '</span></div>';
2294 }
2295 $html .= '<div class="form-text">' . $this->h($texts->get_fd_message('group_image_hint')) . '</div>';
2296 $html .= '</div>';
2297 return $html;
2298 }
2299
2300 private function productChannelsPanel(array $product, bool $isNew, $texts): string {
2301 if (!$this->channelsEnabled()) {
2302 return '';
2303 }
2304 if ($isNew) {
2305 return '<aside class="border rounded bg-light p-3 mt-3"><h6 class="mb-3">'
2306 . $this->h($texts->get_fd_message('channels_title'))
2307 . '</h6><div class="alert alert-info mb-0">'
2308 . $this->h($texts->get_fd_message('save_first_channels'))
2309 . '</div></aside>';
2310 }
2311
2312 $productId = (int)($product['id'] ?? 0);
2313 $overrides = $this->repo()->productChannelOverrides($productId);
2314 $inherited = $this->repo()->inheritedChannelsForProduct($productId);
2315 $html = '<aside class="border rounded bg-light p-3 mt-3 dbx-shop-product-channel-panel">';
2316 $html .= '<h6 class="mb-2">' . $this->h($texts->get_fd_message('channels_title')) . '</h6>';
2317 $html .= '<p class="form-text mb-3">' . $this->h($texts->get_fd_message('channels_info')) . '</p>';
2318 $html .= '<input type="hidden" name="product_channel_editor" value="1">';
2319
2320 $channels = $this->repo()->channels();
2321 if ($channels === array()) {
2322 $html .= '<div class="text-muted small">' . $this->h($texts->get_fd_message('no_channels')) . '</div>';
2323 } else {
2324 $html .= '<div class="table-responsive dbx-shop-product-channel-table-wrap">';
2325 $html .= '<table class="table table-sm align-middle mb-0 dbx-shop-product-channel-table">';
2326 $html .= '<thead><tr><th>' . $this->h($texts->get_fd_message('table_channel')) . '</th><th>' . $this->h($texts->get_fd_message('table_status')) . '</th><th>' . $this->h($texts->get_fd_message('table_export')) . '</th><th class="text-end">' . $this->h($texts->get_fd_message('table_action')) . '</th></tr></thead><tbody>';
2327 foreach ($channels as $channel) {
2328 $key = trim((string)($channel['channel_key'] ?? ''));
2329 if ($key === '') {
2330 continue;
2331 }
2332
2333 $hasOverride = isset($overrides[$key]);
2334 $isInherited = isset($inherited[$key]);
2335 $checked = $hasOverride
2336 ? (int)($overrides[$key]['active'] ?? 0) === 1
2337 : $isInherited;
2338 $source = $hasOverride
2339 ? ((int)($overrides[$key]['active'] ?? 0) === 1
2340 ? $texts->get_fd_message('channel_direct_active')
2341 : $texts->get_fd_message('channel_direct_inactive'))
2342 : ($isInherited
2343 ? $texts->format_fd_message(
2344 'channel_from_group_title',
2345 array('groups' => implode(', ', array_values($inherited[$key]['group_titles'] ?? array())))
2346 )
2347 : $texts->get_fd_message('channel_not_set'));
2348 $sourceText = (!$hasOverride && $isInherited)
2349 ? $texts->get_fd_message('channel_from_group')
2350 : $source;
2351 $statusClass = $checked ? 'text-bg-success' : 'text-bg-secondary';
2352 $export = $overrides[$key] ?? array();
2353 $exportStatus = trim((string)($export['export_status'] ?? ''));
2354 $exportMessage = trim((string)($export['export_message'] ?? ''));
2355 $listingId = trim((string)($export['external_listing_id'] ?? ''));
2356 $exportBadgeClass = match ($exportStatus) {
2357 'published', 'exported', 'ready', 'manual_ready' => 'text-bg-info',
2358 'failed' => 'text-bg-danger',
2359 default => 'text-bg-light text-dark',
2360 };
2361 $exportText = $exportStatus !== ''
2362 ? $exportStatus
2363 : $texts->get_fd_message('channel_not_exported');
2364 $exportUrl = $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $productId . '&export_channel=' . rawurlencode($key));
2365 $mappingUrl = '?dbx_modul=dbxShop_admin&dbx_run1=product_channel_mapping&id=' . $productId . '&channel=' . rawurlencode($key);
2366
2367 $html .= '<tr class="dbx-shop-product-channel-row">';
2368 $html .= '<td class="dbx-shop-product-channel-name">';
2369 $html .= '<label class="d-flex align-items-start gap-2 mb-0">';
2370 $html .= '<input class="form-check-input" type="checkbox" name="product_channels[]" value="' . $this->h($key) . '"' . ($checked ? ' checked' : '') . '>';
2371 $html .= '<span class="dbx-shop-product-channel-copy"><strong>' . $this->h($channel['title'] ?? $key) . '</strong><code>' . $this->h($key) . '</code></span>';
2372 $html .= '</label>';
2373 $html .= '</td>';
2374 $html .= '<td><span class="badge ' . $statusClass . '" title="' . $this->h($source) . '">' . $this->h($sourceText) . '</span></td>';
2375 $html .= '<td><span class="badge ' . $exportBadgeClass . '" title="' . $this->h($exportMessage) . '">' . $this->h($exportText) . '</span>';
2376 if ($listingId !== '') {
2377 $html .= '<code class="small d-block mt-1">' . $this->h($listingId) . '</code>';
2378 }
2379 $html .= '</td>';
2380 $html .= '<td class="text-end"><span class="dbx-shop-product-channel-actions">';
2381 $html .= $this->openWinButton(
2382 $mappingUrl,
2383 $texts->format_fd_message(
2384 'mapping_title',
2385 array('channel' => (string)($channel['title'] ?? $key))
2386 ),
2387 '<i class="bi bi-sliders2"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('mapping_label')) . '</span>',
2388 'btn btn-outline-secondary btn-sm',
2389 '68%',
2390 '84%'
2391 );
2392 if ($checked && (int)($channel['export_enabled'] ?? 0) === 1) {
2393 $html .= '<a class="btn btn-outline-primary btn-sm dbxConfirm" href="' . $this->h($exportUrl) . '" data-confirm-title="<i class=\'bi bi-broadcast\'></i> ' . $this->h($texts->get_fd_message('export_title')) . '" data-confirm="' . $this->h($texts->format_fd_message('export_question', array('channel' => (string)($channel['title'] ?? $key)))) . '" data-confirm-hint="<small>' . $this->h($texts->get_fd_message('export_hint')) . '</small>" data-confirm-buttons="yesno" title="' . $this->h($texts->get_fd_message('export_button_title')) . '"><i class="bi bi-broadcast"></i></a>';
2394 }
2395 $html .= '</span></td>';
2396 $html .= '</tr>';
2397 }
2398 $html .= '</tbody></table></div>';
2399 }
2400
2401 $html .= '</aside>';
2402 return $html;
2403 }
2404
2405 private function mappingLinesToMap(string $lines): array {
2406 $map = array();
2407 foreach (preg_split('/\R/', $lines) ?: array() as $line) {
2408 $line = trim((string)$line);
2409 if ($line === '' || strpos($line, '=') === false) {
2410 continue;
2411 }
2412 [$key, $value] = array_map('trim', explode('=', $line, 2));
2413 if ($key !== '' && $value !== '') {
2414 $map[$key] = $value;
2415 }
2416 }
2417 return $map;
2418 }
2419
2420 private function mappingMapToLines(array $map): string {
2421 $lines = array();
2422 foreach ($map as $key => $value) {
2423 if (is_array($value)) {
2424 $value = implode('|', array_map('strval', $value));
2425 }
2426 $lines[] = (string)$key . '=' . (string)$value;
2427 }
2428 return implode("\n", $lines);
2429 }
2430
2431 private function ebayCategoryOptions(array $mapping, array $channel, array $product, $texts): array {
2432 $options = array();
2433 $add = function(string $value, string $label) use (&$options) {
2434 $value = trim($value);
2435 if ($value === '' || isset($options[$value])) {
2436 return;
2437 }
2438 $options[$value] = $label;
2439 };
2440
2441 $current = (string)($mapping['category_id'] ?? '');
2442 $channelDefault = (string)($channel['category_id'] ?? '');
2443 $groupDefault = (string)($this->productGroupChannelMappingDefaults('ebay', $product)['category_id'] ?? '');
2444 $add($current, $texts->format_fd_message('mapping_current_selection', array('value' => $current)));
2445 $add($groupDefault, $texts->format_fd_message('mapping_group_default', array('value' => $groupDefault)));
2446 $add($channelDefault, $texts->format_fd_message('mapping_channel_default', array('value' => $channelDefault)));
2447
2448 $configured = dbx()->get_config('dbxShop', 'ebay_category_options');
2449 if (is_array($configured)) {
2450 foreach ($configured as $value => $label) {
2451 $add((string)$value, (string)$label);
2452 }
2453 } else {
2454 foreach (preg_split('/\R/', (string)$configured) ?: array() as $line) {
2455 $line = trim($line);
2456 if ($line === '') {
2457 continue;
2458 }
2459 if (strpos($line, '=') !== false) {
2460 [$value, $label] = array_map('trim', explode('=', $line, 2));
2461 $add($value, $label !== '' ? $label : $value);
2462 } else {
2463 $add($line, $line);
2464 }
2465 }
2466 }
2467
2468 $productCategory = strtolower((string)($product['category'] ?? '') . ' ' . (string)($product['product_type'] ?? ''));
2469 if (strpos($productCategory, 'software') !== false || strpos($productCategory, 'digital') !== false) {
2470 $add('58058', '58058 - ' . $texts->get_fd_message('mapping_category_software'));
2471 }
2472
2473 $add('58058', '58058 - ' . $texts->get_fd_message('mapping_category_software'));
2474 $add('11450', '11450 - ' . $texts->get_fd_message('mapping_category_clothing'));
2475 $add('293', '293 - ' . $texts->get_fd_message('mapping_category_electronics'));
2476 $add('220', '220 - ' . $texts->get_fd_message('mapping_category_home'));
2477 $add('12576', '12576 - ' . $texts->get_fd_message('mapping_category_business'));
2478
2479 return $options;
2480 }
2481
2482 private function ebayCategoryInput(array $mapping, array $channel, array $product, $texts): string {
2483 $value = (string)($mapping['category_id'] ?? $channel['category_id'] ?? '');
2484 $html = '<div class="col-md-4"><label class="form-label">' . $this->h($texts->get_fd_message('mapping_ebay_category')) . '</label>'
2485 . '<select class="form-select form-select-sm" name="mapping_category_id">';
2486 foreach ($this->ebayCategoryOptions($mapping, $channel, $product, $texts) as $optionValue => $label) {
2487 $html .= '<option value="' . $this->h($optionValue) . '"' . ((string)$optionValue === $value ? ' selected' : '') . '>' . $this->h($label) . '</option>';
2488 }
2489 $html .= '</select>'
2490 . '<div class="form-text">' . $this->h($texts->get_fd_message('mapping_ebay_category_hint')) . '</div>'
2491 . '</div>';
2492 return $html;
2493 }
2494
2495 private function productGroupChannelMappingDefaults(string $platform, array $product): array {
2496 $groupId = (int)($product['product_group_id'] ?? 0);
2497 if ($groupId <= 0) {
2498 return array();
2499 }
2500 $group = $this->repo()->groupById($groupId);
2501 if (!is_array($group)) {
2502 return array();
2503 }
2504
2505 if ($platform === 'ebay') {
2506 $category = trim((string)($group['ebay_category_id'] ?? ''));
2507 return $category !== '' ? array('category_id' => $category) : array();
2508 }
2509 if ($platform === 'amazon') {
2510 $productType = trim((string)($group['amazon_product_type'] ?? ''));
2511 return $productType !== '' ? array('productType' => $productType) : array();
2512 }
2513 if ($platform === 'kleinanzeigen') {
2514 $category = trim((string)($group['kleinanzeigen_category_id'] ?? ''));
2515 return $category !== '' ? array('category_id' => $category) : array();
2516 }
2517 if ($platform === 'mobile') {
2518 $category = trim((string)($group['mobile_category_id'] ?? ''));
2519 return $category !== '' ? array('mobile_vehicle' => array('category' => $category)) : array();
2520 }
2521
2522 return array();
2523 }
2524
2525 private function channelMappingInheritedDefaults(string $platform, array $channel, array $product): array {
2526 $defaults = array();
2527 if ($platform === 'ebay') {
2528 $defaults = array(
2529 'category_id' => trim((string)($channel['category_id'] ?? '')),
2530 'payment_policy_id' => trim((string)($channel['payment_policy_id'] ?? '')),
2531 'fulfillment_policy_id' => trim((string)($channel['fulfillment_policy_id'] ?? '')),
2532 'return_policy_id' => trim((string)($channel['return_policy_id'] ?? '')),
2533 );
2534 } elseif ($platform === 'amazon') {
2535 $category = trim((string)($channel['category_id'] ?? ''));
2536 if (stripos($category, 'productType:') === 0) {
2537 $category = trim(substr($category, strlen('productType:')));
2538 }
2539 if (strpos($category, '/') !== false) {
2540 $category = trim((string)explode('/', $category)[0]);
2541 }
2542 $defaults = array('productType' => strtoupper($category));
2543 } elseif ($platform === 'kleinanzeigen') {
2544 $defaults = array(
2545 'category_id' => trim((string)($channel['category_id'] ?? '')),
2546 'location' => trim((string)($channel['location_key'] ?? '')),
2547 );
2548 } elseif ($platform === 'mobile') {
2549 $category = trim((string)($channel['category_id'] ?? ''));
2550 $defaults = array('mobile_vehicle' => array('category' => $category));
2551 }
2552
2553 $defaults = $this->mergeMappingDefaults($defaults, $this->productGroupChannelMappingDefaults($platform, $product));
2554 return $this->cleanEmptyMappingValues($defaults);
2555 }
2556
2557 private function mergeMappingDefaults(array $defaults, array $mapping): array {
2558 foreach ($defaults as $key => $value) {
2559 if (is_array($value)) {
2560 $current = is_array($mapping[$key] ?? null) ? $mapping[$key] : array();
2561 $mapping[$key] = $this->mergeMappingDefaults($value, $current);
2562 continue;
2563 }
2564 if (!array_key_exists($key, $mapping) || trim((string)$mapping[$key]) === '') {
2565 $mapping[$key] = $value;
2566 }
2567 }
2568 return $mapping;
2569 }
2570
2571 private function cleanEmptyMappingValues(array $mapping): array {
2572 foreach ($mapping as $key => $value) {
2573 if (is_array($value)) {
2574 $value = $this->cleanEmptyMappingValues($value);
2575 if ($value === array()) {
2576 unset($mapping[$key]);
2577 } else {
2578 $mapping[$key] = $value;
2579 }
2580 continue;
2581 }
2582 if (trim((string)$value) === '') {
2583 unset($mapping[$key]);
2584 }
2585 }
2586 return $mapping;
2587 }
2588
2589 private function removeInheritedMappingDefaults(array $mapping, array $defaults): array {
2590 foreach ($defaults as $key => $defaultValue) {
2591 if (!array_key_exists($key, $mapping)) {
2592 continue;
2593 }
2594 if (is_array($defaultValue)) {
2595 $current = is_array($mapping[$key]) ? $mapping[$key] : array();
2596 $mapping[$key] = $this->removeInheritedMappingDefaults($current, $defaultValue);
2597 if ($mapping[$key] === array()) {
2598 unset($mapping[$key]);
2599 }
2600 continue;
2601 }
2602 if (trim((string)$mapping[$key]) === trim((string)$defaultValue)) {
2603 unset($mapping[$key]);
2604 }
2605 }
2606 return $this->cleanEmptyMappingValues($mapping);
2607 }
2608
2609 private function providerMappingHtml(string $platform, array $mapping, array $channel, array $product, $texts): string {
2610 $input = function(string $name, string $label, string $value = '', string $placeholder = '', string $class = 'col-md-4'): string {
2611 return '<div class="' . $class . '"><label class="form-label">' . $this->h($label) . '</label><input class="form-control form-control-sm" name="mapping_' . $this->h($name) . '" value="' . $this->h($value) . '" placeholder="' . $this->h($placeholder) . '"></div>';
2612 };
2613 $textarea = function(string $name, string $label, string $value = '', string $placeholder = '', int $rows = 4, string $class = 'col-12'): string {
2614 return '<div class="' . $class . '"><label class="form-label">' . $this->h($label) . '</label><textarea class="form-control form-control-sm" rows="' . $rows . '" name="mapping_' . $this->h($name) . '" placeholder="' . $this->h($placeholder) . '">' . $this->h($value) . '</textarea></div>';
2615 };
2616
2617 $html = '<div class="row g-3">';
2618 if ($platform === 'ebay') {
2619 $condition = (string)($mapping['condition'] ?? 'NEW');
2620 $aspects = is_array($mapping['aspects'] ?? null) ? $this->mappingMapToLines($mapping['aspects']) : '';
2621 $locationKey = trim((string)($channel['location_key'] ?? ''));
2622 $html .= $this->ebayCategoryInput($mapping, $channel, $product, $texts);
2623 $html .= '<div class="col-md-4"><label class="form-label">' . $this->h($texts->get_fd_message('mapping_location_key')) . '</label>'
2624 . '<input class="form-control form-control-sm" value="' . $this->h($locationKey) . '" placeholder="' . $this->h($texts->get_fd_message('mapping_location_placeholder')) . '" readonly>'
2625 . '<div class="form-text">' . $this->h($texts->get_fd_message('mapping_location_hint')) . '</div></div>';
2626 $html .= '<div class="col-md-4"><label class="form-label">' . $this->h($texts->get_fd_message('mapping_condition')) . '</label><select class="form-select form-select-sm" name="mapping_condition">'
2627 . '<option value="NEW"' . ($condition === 'NEW' ? ' selected' : '') . '>' . $this->h($texts->get_fd_message('mapping_condition_new')) . '</option>'
2628 . '<option value="USED_EXCELLENT"' . ($condition === 'USED_EXCELLENT' ? ' selected' : '') . '>' . $this->h($texts->get_fd_message('mapping_condition_used_excellent')) . '</option>'
2629 . '<option value="USED_GOOD"' . ($condition === 'USED_GOOD' ? ' selected' : '') . '>' . $this->h($texts->get_fd_message('mapping_condition_used_good')) . '</option>'
2630 . '</select></div>';
2631 $html .= $input('payment_policy_id', 'Payment-Policy-ID', (string)($mapping['payment_policy_id'] ?? $channel['payment_policy_id'] ?? ''), 'policy_payment_1234567890');
2632 $html .= $input('fulfillment_policy_id', 'Fulfillment-Policy-ID', (string)($mapping['fulfillment_policy_id'] ?? $channel['fulfillment_policy_id'] ?? ''), 'policy_fulfillment_1234567890');
2633 $html .= $input('return_policy_id', 'Return-Policy-ID', (string)($mapping['return_policy_id'] ?? $channel['return_policy_id'] ?? ''), 'policy_return_1234567890');
2634 $html .= $textarea('aspects', $texts->get_fd_message('mapping_ebay_aspects'), $aspects, "brand=dbxApp\ncolor=black\nsize=L", 5);
2635 } elseif ($platform === 'amazon') {
2636 $simple = is_array($mapping['simple_attributes'] ?? null) ? $this->mappingMapToLines($mapping['simple_attributes']) : '';
2637 $html .= $input('productType', 'Amazon Product Type', (string)($mapping['productType'] ?? $mapping['product_type'] ?? ''), 'SOFTWARE / PRODUCT / SHIRT');
2638 $html .= '<div class="col-md-4"><label class="form-label">' . $this->h($texts->get_fd_message('mapping_requirements')) . '</label><select class="form-select form-select-sm" name="mapping_requirements">'
2639 . '<option value="LISTING"' . ((string)($mapping['requirements'] ?? 'LISTING') === 'LISTING' ? ' selected' : '') . '>' . $this->h($texts->get_fd_message('mapping_requirements_listing')) . '</option>'
2640 . '<option value="LISTING_PRODUCT_ONLY"' . ((string)($mapping['requirements'] ?? '') === 'LISTING_PRODUCT_ONLY' ? ' selected' : '') . '>' . $this->h($texts->get_fd_message('mapping_requirements_product')) . '</option>'
2641 . '<option value="LISTING_OFFER_ONLY"' . ((string)($mapping['requirements'] ?? '') === 'LISTING_OFFER_ONLY' ? ' selected' : '') . '>' . $this->h($texts->get_fd_message('mapping_requirements_offer')) . '</option>'
2642 . '</select></div>';
2643 $html .= $input('brand', $texts->get_fd_message('mapping_brand'), (string)($mapping['simple_attributes']['brand'] ?? 'dbxApp'), 'dbxApp');
2644 $html .= $textarea('simple_attributes', $texts->get_fd_message('mapping_amazon_attributes'), $simple, "manufacturer=dbxApp\nitem_type_keyword=software\nrecommended_browse_nodes=123456", 6);
2645 $html .= '<div class="col-12"><div class="form-text">' . $this->h($texts->get_fd_message('mapping_amazon_hint')) . '</div></div>';
2646 } elseif ($platform === 'mobile') {
2647 $vehicle = is_array($mapping['mobile_vehicle'] ?? null) ? $mapping['mobile_vehicle'] : array();
2648 $html .= $input('vehicle_make', $texts->get_fd_message('mapping_brand'), (string)($vehicle['make'] ?? ''), 'Volkswagen');
2649 $html .= $input('vehicle_model', $texts->get_fd_message('mapping_vehicle_model'), (string)($vehicle['model'] ?? ''), 'Golf');
2650 $html .= $input('vehicle_first_registration', $texts->get_fd_message('mapping_vehicle_registration'), (string)($vehicle['firstRegistration'] ?? ''), '2023-05');
2651 $html .= $input('vehicle_mileage', $texts->get_fd_message('mapping_vehicle_mileage'), (string)($vehicle['mileage'] ?? ''), '25000');
2652 $html .= $input('vehicle_fuel', $texts->get_fd_message('mapping_vehicle_fuel'), (string)($vehicle['fuel'] ?? ''), 'PETROL');
2653 $html .= $input('vehicle_power', $texts->get_fd_message('mapping_vehicle_power'), (string)($vehicle['power'] ?? ''), '110');
2654 $html .= $input('vehicle_category', $texts->get_fd_message('mapping_vehicle_category'), (string)($vehicle['category'] ?? $channel['category_id'] ?? ''), 'car');
2655 $html .= $textarea('vehicle_extra', $texts->get_fd_message('mapping_vehicle_extra'), is_array($mapping['vehicle_extra'] ?? null) ? $this->mappingMapToLines($mapping['vehicle_extra']) : '', "gearbox=MANUAL\nemissionClass=EURO6", 5);
2656 $html .= '<div class="col-12"><div class="form-text">' . $this->h($texts->get_fd_message('mapping_mobile_hint')) . '</div></div>';
2657 } elseif ($platform === 'kleinanzeigen') {
2658 $attrs = is_array($mapping['attributes'] ?? null) ? $this->mappingMapToLines($mapping['attributes']) : '';
2659 $html .= $input('category_id', $texts->get_fd_message('mapping_classified_category'), (string)($mapping['category_id'] ?? $channel['category_id'] ?? ''), 'category_12345');
2660 $html .= $input('location', $texts->get_fd_message('mapping_place'), (string)($mapping['location'] ?? ''), '10115 Berlin');
2661 $html .= $input('contact_name', $texts->get_fd_message('mapping_contact_name'), (string)($mapping['contact_name'] ?? ''), 'Muster GmbH');
2662 $html .= $input('phone', $texts->get_fd_message('mapping_phone'), (string)($mapping['phone'] ?? ''), '+49...');
2663 $html .= $textarea('attributes', $texts->get_fd_message('mapping_classified_attributes'), $attrs, "condition=new\nshipping=yes\ncolor=black", 5);
2664 $html .= '<div class="col-12"><div class="form-text">' . $this->h($texts->get_fd_message('mapping_classified_hint')) . '</div></div>';
2665 } else {
2666 $attrs = is_array($mapping['attributes'] ?? null) ? $this->mappingMapToLines($mapping['attributes']) : '';
2667 $html .= $input('endpoint_action', $texts->get_fd_message('mapping_endpoint'), (string)($mapping['endpoint_action'] ?? ''), 'products.upsert');
2668 $html .= $textarea('attributes', $texts->get_fd_message('mapping_middleware_attributes'), $attrs, "external_category=123\nbrand=dbxApp", 6);
2669 }
2670 $html .= '</div>';
2671 return $html;
2672 }
2673
2674 private function collectProductChannelMapping(string $platform, array $defaults = array()): array {
2675 if ($platform === 'ebay') {
2676 return array(
2677 'category_id' => trim((string)($_POST['mapping_category_id'] ?? '')),
2678 'condition' => trim((string)($_POST['mapping_condition'] ?? 'NEW')),
2679 'payment_policy_id' => trim((string)($_POST['mapping_payment_policy_id'] ?? '')),
2680 'fulfillment_policy_id' => trim((string)($_POST['mapping_fulfillment_policy_id'] ?? '')),
2681 'return_policy_id' => trim((string)($_POST['mapping_return_policy_id'] ?? '')),
2682 'aspects' => $this->mappingLinesToMap((string)($_POST['mapping_aspects'] ?? '')),
2683 );
2684 }
2685 if ($platform === 'amazon') {
2686 $simple = $this->mappingLinesToMap((string)($_POST['mapping_simple_attributes'] ?? ''));
2687 $brand = trim((string)($_POST['mapping_brand'] ?? ''));
2688 if ($brand !== '') {
2689 $simple['brand'] = $brand;
2690 }
2691 return array(
2692 'productType' => trim((string)($_POST['mapping_productType'] ?? '')),
2693 'requirements' => trim((string)($_POST['mapping_requirements'] ?? 'LISTING')),
2694 'simple_attributes' => $simple,
2695 );
2696 }
2697 if ($platform === 'mobile') {
2698 $vehicle = array(
2699 'make' => trim((string)($_POST['mapping_vehicle_make'] ?? '')),
2700 'model' => trim((string)($_POST['mapping_vehicle_model'] ?? '')),
2701 'firstRegistration' => trim((string)($_POST['mapping_vehicle_first_registration'] ?? '')),
2702 'mileage' => trim((string)($_POST['mapping_vehicle_mileage'] ?? '')),
2703 'fuel' => trim((string)($_POST['mapping_vehicle_fuel'] ?? '')),
2704 'power' => trim((string)($_POST['mapping_vehicle_power'] ?? '')),
2705 'category' => trim((string)($_POST['mapping_vehicle_category'] ?? '')),
2706 );
2707 $vehicle = array_filter($vehicle, fn($value) => trim((string)$value) !== '');
2708 return array(
2709 'mobile_vehicle' => $vehicle,
2710 'vehicle_extra' => $this->mappingLinesToMap((string)($_POST['mapping_vehicle_extra'] ?? '')),
2711 );
2712 }
2713 if ($platform === 'kleinanzeigen') {
2714 return array(
2715 'category_id' => trim((string)($_POST['mapping_category_id'] ?? '')),
2716 'location' => trim((string)($_POST['mapping_location'] ?? '')),
2717 'contact_name' => trim((string)($_POST['mapping_contact_name'] ?? '')),
2718 'phone' => trim((string)($_POST['mapping_phone'] ?? '')),
2719 'attributes' => $this->mappingLinesToMap((string)($_POST['mapping_attributes'] ?? '')),
2720 );
2721 }
2722 return array(
2723 'endpoint_action' => trim((string)($_POST['mapping_endpoint_action'] ?? '')),
2724 'attributes' => $this->mappingLinesToMap((string)($_POST['mapping_attributes'] ?? '')),
2725 );
2726 }
2727
2728 private function normalizeDecimalInput($value): string {
2729 $value = str_replace(',', '.', trim((string)$value));
2730 if ($value === '') {
2731 return '';
2732 }
2733 return number_format((float)$value, 2, '.', '');
2734 }
2735
2736 private function channelInheritedDecimalValue(string $postedName): string {
2737 $value = $this->normalizeDecimalInput($_POST[$postedName] ?? '');
2738 $inherited = (string)($_POST[$postedName . '_inherited'] ?? '') === '1';
2739 $inheritedValue = $this->normalizeDecimalInput($_POST[$postedName . '_inherited_value'] ?? '');
2740 if ($value === '') {
2741 return '-1';
2742 }
2743 if ($inherited && $inheritedValue !== '' && $value === $inheritedValue) {
2744 return '-1';
2745 }
2746 return $value;
2747 }
2748
2749 private function channelMappingDisplayValues(array $product, array $productChannel): array {
2750 $storedPrice = (float)($productChannel['price_gross'] ?? -1);
2751 $storedShipping = (float)($productChannel['shipping_gross'] ?? -1);
2752 $inheritedPrice = number_format((float)($product['price_gross'] ?? 0), 2, '.', '');
2753 $inheritedShipping = number_format((float)($product['effective_shipping_gross'] ?? $product['shipping_gross'] ?? 0), 2, '.', '');
2754 $priceInherited = $storedPrice < 0;
2755 $shippingInherited = $storedShipping < 0;
2756
2757 return array(
2758 'price_gross' => $priceInherited ? $inheritedPrice : number_format($storedPrice, 2, '.', ''),
2759 'shipping_gross' => $shippingInherited ? $inheritedShipping : number_format($storedShipping, 2, '.', ''),
2760 'price_gross_inherited' => $priceInherited ? '1' : '0',
2761 'price_gross_inherited_value' => $inheritedPrice,
2762 'shipping_gross_inherited' => $shippingInherited ? '1' : '0',
2763 'shipping_gross_inherited_value' => $inheritedShipping,
2764 );
2765 }
2766
2767 private function productChannelMapping(): string {
2768 $this->ensureSeed();
2769 dbx()->get_system_obj('dbxForm', 'use');
2770 $texts = new \dbxForm();
2771 $texts->init('shop-product-channel-mapping-texts');
2772 $texts->_fd = 'dbxShop|shop-product-channel';
2773 $texts->load_fd_messages();
2774 $texts->set_form_help_enabled(false);
2775 $productId = (int)dbx()->get_modul_var('id', 0, 'int');
2776 $channelKey = trim((string)dbx()->get_modul_var('channel', '', 'parameter'));
2777 if ($productId <= 0 || $channelKey === '') {
2778 return $this->frame('<div class="alert alert-warning m-3">' . $this->h($texts->get_fd_message('mapping_missing')) . '</div>', $texts->get_fd_message('mapping_title'));
2779 }
2780
2781 $state = $this->repo()->productChannelMapping($productId, $channelKey);
2782 if (!$state) {
2783 return $this->frame('<div class="alert alert-warning m-3">' . $this->h($texts->get_fd_message('mapping_not_found')) . '</div>', $texts->get_fd_message('mapping_title'));
2784 }
2785
2786 $product = (array)$state['product'];
2787 $channel = (array)$state['channel'];
2788 $productChannel = (array)$state['product_channel'];
2789 $mapping = (array)$state['mapping'];
2790 $platform = (string)($channel['platform_type'] ?? 'custom');
2791 $mappingDefaults = $this->channelMappingInheritedDefaults($platform, $channel, $product);
2792 $mapping = $this->mergeMappingDefaults($mappingDefaults, $mapping);
2793 $message = '';
2794
2795 if ($this->posted('save_channel_mapping')) {
2796 $saveProductId = (int)($_POST['product_id'] ?? $productId);
2797 $saveChannelKey = trim((string)($_POST['channel_key_ref'] ?? $channelKey));
2798 if ($saveProductId === $productId && $saveChannelKey === $channelKey) {
2799 $this->repo()->saveProductChannelMapping($productId, $channelKey, array(
2800 'active' => !empty($_POST['active']) ? 1 : 0,
2801 'channel_sku' => (string)($_POST['channel_sku'] ?? ''),
2802 'price_gross' => $this->channelInheritedDecimalValue('price_gross'),
2803 'shipping_gross' => $this->channelInheritedDecimalValue('shipping_gross'),
2804 'external_listing_id' => (string)($_POST['external_listing_id'] ?? ''),
2805 'external_offer_id' => (string)($_POST['external_offer_id'] ?? ''),
2806 'mapping' => $this->removeInheritedMappingDefaults($this->collectProductChannelMapping($platform, $mappingDefaults), $mappingDefaults),
2807 ));
2808 $message = $texts->get_fd_message('mapping_saved');
2809 $state = $this->repo()->productChannelMapping($productId, $channelKey) ?: $state;
2810 $productChannel = (array)$state['product_channel'];
2811 $mapping = (array)$state['mapping'];
2812 $mappingDefaults = $this->channelMappingInheritedDefaults($platform, $channel, $product);
2813 $mapping = $this->mergeMappingDefaults($mappingDefaults, $mapping);
2814 }
2815 }
2816
2817 $displayValues = $this->channelMappingDisplayValues($product, $productChannel);
2818 $form = dbx()->get_system_obj('dbxForm');
2819 $form->init('shop-product-channel-mapping-' . $productId . '-' . $channelKey, 'shop-product-channel-mapping');
2820 $form->_dd = 'dbxShop|shopProductChannel';
2821 $form->_fd = 'dbxShop|shop-product-channel';
2822 $form->load_fd_messages();
2823 $form->_data = array_merge($productChannel + array(
2824 'product_id' => $productId,
2825 'channel_key' => $channelKey,
2826 'active' => 1,
2827 'channel_sku' => (string)($product['sku'] ?? ''),
2828 'price_gross' => -1,
2829 'shipping_gross' => -1,
2830 ), array(
2831 'price_gross' => $displayValues['price_gross'],
2832 'shipping_gross' => $displayValues['shipping_gross'],
2833 ));
2834 $form->_rid = (int)($productChannel['id'] ?? 0);
2835 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=product_channel_mapping&id=' . $productId . '&channel=' . rawurlencode($channelKey);
2836 $form->set_activ_id((int)($productChannel['id'] ?? 0));
2837 $form->_msg_info = '';
2838 $form->add_rep('shop_admin_style', $this->shopAdminStyle());
2839 $form->add_rep('bar_class', 'dbx-module-bar');
2840 $form->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
2841 $form->add_rep('bar_actions_class', 'dbx-module-bar-actions');
2842 $form->add_rep('bar_icon', 'bi-sliders2');
2843 $form->add_rep('bar_title', $this->h($texts->get_fd_message('mapping_title')));
2844 $form->add_rep('bar_subtitle', $this->h((string)($product['sku'] ?? '') . ' - ' . (string)($channel['title'] ?? $channelKey)));
2845 $form->add_rep('bar_actions', $this->helpButton($this->ensureShopProductChannelMappingHelpPage(), $texts->get_fd_message('mapping_help'), 'btn btn-outline-secondary btn-sm')
2846 . '<button class="btn btn-primary btn-sm" type="submit" title="' . $this->h($texts->get_fd_message('mapping_save')) . '"><i class="bi bi-save"></i> ' . $this->h($texts->get_fd_message('mapping_save')) . '</button>'
2847 . '<a class="btn btn-outline-primary btn-sm dbxConfirm" href="' . $this->h($this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $productId . '&export_channel=' . rawurlencode($channelKey))) . '" data-confirm-title="<i class=\'bi bi-broadcast\'></i> ' . $this->h($texts->get_fd_message('mapping_export_title')) . '" data-confirm="' . $this->h($texts->get_fd_message('mapping_export_confirm')) . '" data-confirm-buttons="yesno" title="' . $this->h($texts->get_fd_message('mapping_export_label')) . '"><i class="bi bi-broadcast"></i></a>');
2848 $form->add_rep('product_id', (string)$productId);
2849 $form->add_rep('channel_key', $this->h($channelKey));
2850 $form->add_rep('price_gross_inherited', $displayValues['price_gross_inherited']);
2851 $form->add_rep('price_gross_inherited_value', $displayValues['price_gross_inherited_value']);
2852 $form->add_rep('shipping_gross_inherited', $displayValues['shipping_gross_inherited']);
2853 $form->add_rep('shipping_gross_inherited_value', $displayValues['shipping_gross_inherited_value']);
2854 $form->add_rep('mapping_message', $message !== '' ? '<div class="alert alert-success mb-3">' . $this->h($message) . '</div>' : '');
2855 $form->add_rep('mapping_intro', $this->h($texts->get_fd_message('mapping_intro')));
2856 $form->add_rep('mapping_values_title', $this->h($texts->get_fd_message('mapping_values_title')));
2857 $form->add_rep('mapping_export_status_title', $this->h($texts->get_fd_message('mapping_export_status_title')));
2858 $form->add_rep('provider_title', $this->h(($channel['title'] ?? $channelKey) . ' Mapping'));
2859 $form->add_rep('export_status_view', $this->productChannelExportStatusHtml($productChannel, $texts));
2860 $form->add_fld('active');
2861 $form->add_fld('channel_sku', placeholder: (string)($product['sku'] ?? ''));
2862 $form->add_fld('price_gross', placeholder: $displayValues['price_gross']);
2863 $form->add_fld('shipping_gross', placeholder: $displayValues['shipping_gross']);
2864 $form->add_fld('external_listing_id');
2865 $form->add_fld('external_offer_id');
2866 $form->add_obj('provider_mapping', 'obj-value', $this->providerMappingHtml($platform, $mapping, $channel, $product, $texts));
2867 return $form->run();
2868 }
2869
2870 private function productChannelExportStatusHtml(array $productChannel, $texts): string {
2871 $status = trim((string)($productChannel['export_status'] ?? ''));
2872 $message = trim((string)($productChannel['export_message'] ?? ''));
2873 $listing = trim((string)($productChannel['external_listing_id'] ?? ''));
2874 $offer = trim((string)($productChannel['external_offer_id'] ?? ''));
2875 $date = trim((string)($productChannel['last_export_date'] ?? ''));
2876 $html = '<div class="d-flex flex-wrap gap-2 align-items-center">';
2877 $html .= '<span class="badge text-bg-' . ($status === 'failed' ? 'danger' : ($status !== '' ? 'info' : 'secondary')) . '">' . $this->h($status !== '' ? $status : $texts->get_fd_message('mapping_not_exported')) . '</span>';
2878 if ($date !== '') $html .= '<span class="text-muted small">' . $this->h($date) . '</span>';
2879 if ($listing !== '') $html .= '<code>Listing: ' . $this->h($listing) . '</code>';
2880 if ($offer !== '') $html .= '<code>Offer: ' . $this->h($offer) . '</code>';
2881 $html .= '</div>';
2882 if ($message !== '') {
2883 $html .= '<div class="alert alert-secondary py-2 mt-2 mb-0">' . $this->h($message) . '</div>';
2884 }
2885 return $html;
2886 }
2887
2898 private function posted(string $action): bool {
2899 if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST'
2900 || (string)($_POST['shop_action'] ?? '') !== $action) {
2901 return false;
2902 }
2903
2904 $id = max(0, (int)($_POST['id'] ?? 0));
2905 $suffix = $id > 0 ? (string)$id : 'new';
2906 $contract = array();
2907
2908 switch ($action) {
2909 case 'save_channel_mapping':
2910 $productId = max(0, (int)($_POST['product_id'] ?? 0));
2911 $channelKey = trim((string)($_POST['channel_key_ref'] ?? ''));
2912 if ($productId <= 0 || !preg_match('/^[A-Za-z0-9_-]+$/', $channelKey)) {
2913 return false;
2914 }
2915 $contract = array(
2916 'fid' => 'shop-product-channel-mapping-' . $productId . '-' . $channelKey,
2917 'fd' => 'dbxShop|shop-product-channel',
2918 'fields' => array('active', 'channel_sku', 'price_gross', 'shipping_gross', 'external_listing_id', 'external_offer_id'),
2919 );
2920 break;
2921
2922 case 'save_product_group':
2923 $contract = array(
2924 'fid' => 'shop-product-group-' . $suffix,
2925 'fd' => 'dbxShop|shop-product-group',
2926 'fields' => array('group_key', 'parent_id', 'title', 'description', 'tax_class', 'card_template', 'detail_template', 'gallery_template', 'gallery_visible_count', 'gallery_image_size', 'gallery_lightbox_width', 'gallery_overflow', 'gallery_click', 'attribute_notes', 'ebay_category_id', 'amazon_product_type', 'kleinanzeigen_category_id', 'mobile_category_id', 'sorter', 'active'),
2927 );
2928 break;
2929
2930 case 'delete_product_group':
2931 $contract = array('fid' => 'shop-product-group-' . $suffix, 'fd' => '', 'fields' => array());
2932 break;
2933
2934 case 'save_attribute_definition':
2935 $contract = array(
2936 'fid' => 'shop-attribute-definition-' . $suffix,
2937 'fd' => 'dbxShop|shop-attribute-definition',
2938 'fields' => array('group_id', 'attr_key', 'title', 'input_type', 'unit', 'options', 'required', 'filterable', 'comparable', 'sorter', 'active'),
2939 );
2940 break;
2941
2942 case 'save_product_attributes':
2943 $productId = max(0, (int)($_POST['product_id'] ?? 0));
2944 if ($productId <= 0) return false;
2945 $contract = array('fid' => 'shop-product-attributes-' . $productId, 'fd' => '', 'fields' => array());
2946 break;
2947
2948 case 'save_shipping_group':
2949 $contract = array(
2950 'fid' => 'shop-shipping-group-' . $suffix,
2951 'fd' => 'dbxShop|shop-shipping-group',
2952 'fields' => array('group_key', 'title', 'description', 'shipping_way', 'delivery_time', 'shipping_gross', 'free_from_gross', 'sorter', 'active'),
2953 );
2954 break;
2955
2956 case 'delete_shipping_group':
2957 $contract = array('fid' => 'shop-shipping-group-' . $suffix, 'fd' => '', 'fields' => array());
2958 break;
2959
2960 case 'save_channel_group':
2961 $contract = array(
2962 'fid' => 'shop-channel-group-' . $suffix,
2963 'fd' => 'dbxShop|shop-channel-group',
2964 'fields' => array('group_key', 'title', 'description', 'sorter', 'active'),
2965 );
2966 break;
2967
2968 case 'delete_channel_group':
2969 $contract = array('fid' => 'shop-channel-group-' . $suffix, 'fd' => '', 'fields' => array());
2970 break;
2971
2972 case 'save_channel':
2973 case 'test_channel':
2974 $contract = array(
2975 'fid' => 'shop-channel-form-' . $suffix,
2976 'fd' => 'dbxShop|shop-channel',
2977 'fields' => array(),
2978 'all_fields' => true,
2979 );
2980 break;
2981
2982 case 'delete_channel':
2983 $contract = array('fid' => 'shop-channel-form-' . $suffix, 'fd' => '', 'fields' => array());
2984 break;
2985
2986 default:
2987 // Nicht mehr gerenderte Legacy-Aktionen (z. B. alter Medienupload)
2988 // dürfen ohne expliziten dbxForm-Vertrag keine Mutation auslösen.
2989 return false;
2990 }
2991
2992 // get_system_obj lädt die Klasse; eine eigene Instanz verhindert, dass
2993 // der Prüflauf den später gerenderten Karten-Formzustand überschreibt.
2994 dbx()->get_system_obj('dbxForm');
2995 $form = new \dbxForm();
2996 $form->init((string)$contract['fid']);
2997 if ((string)($contract['fd'] ?? '') !== '') {
2998 $form->_fd = (string)$contract['fd'];
2999 }
3000 if (!empty($contract['all_fields'])) {
3001 $form->add_flds();
3002 } else {
3003 foreach ((array)($contract['fields'] ?? array()) as $field) {
3004 // Nicht gerenderte optionale Felder werden nicht künstlich als
3005 // leer validiert. Vorhandene Werte durchlaufen ihre FD-Regeln.
3006 if (array_key_exists($field, $_POST)) {
3007 $form->add_fld($field);
3008 }
3009 }
3010 }
3011
3012 if (!$form->submit()) {
3013 $this->postedFormError = 'Die Sicherheitsprüfung des Formulars ist fehlgeschlagen. Bitte laden Sie die Seite neu und versuchen Sie es erneut.';
3014 dbx()->sys_msg(
3015 'security',
3016 'dbxShop_admin',
3017 $action,
3018 'Shop-Admin-Formular abgewiesen',
3019 'fid=' . (string)$contract['fid'] . ' reason=token ip=' . (string)($_SERVER['REMOTE_ADDR'] ?? '')
3020 );
3021 return false;
3022 }
3023
3024 if ($form->errors()) {
3025 $this->postedFormError = 'Bitte prüfen Sie die markierten beziehungsweise erforderlichen Eingaben.';
3026 dbx()->sys_msg(
3027 'security',
3028 'dbxShop_admin',
3029 $action,
3030 'Shop-Admin-Formular abgewiesen',
3031 'fid=' . (string)$contract['fid'] . ' reason=validation ip=' . (string)($_SERVER['REMOTE_ADDR'] ?? '')
3032 );
3033 return false;
3034 }
3035
3036 if ($action === 'save_product_attributes') {
3037 foreach ((array)($_POST['attr_value'] ?? array()) as $value) {
3038 if (is_array($value) || mb_strlen((string)$value) > 255) {
3039 $this->postedFormError = 'Ein Attributwert ist ungültig oder länger als 255 Zeichen.';
3040 return false;
3041 }
3042 }
3043 }
3044
3045 return true;
3046 }
3047
3048 private function cmsEndpoint(string $run1, array $params = array(), bool $mutating = false): string {
3049 $url = '?dbx_modul=dbxContent_admin&dbx_run1=' . rawurlencode($run1);
3050 foreach ($params as $key => $value) {
3051 $url .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
3052 }
3053 if ($mutating) {
3054 $url .= '&dbx_token=' . rawurlencode(dbx()->action_token('dbxContent_admin.actions'));
3055 }
3056 return $url;
3057 }
3058
3059 private function shopEndpoint(string $run1, array $params = array(), bool $mutating = false): string {
3060 $url = '?dbx_modul=dbxShop_admin&dbx_run1=' . rawurlencode($run1);
3061 foreach ($params as $key => $value) {
3062 $url .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
3063 }
3064 return $mutating ? $this->actionUrl($url) : $url;
3065 }
3066
3067 private function readJsonPayload(): array {
3068 $raw = (string)file_get_contents('php://input');
3069 $data = $raw !== '' ? json_decode($raw, true) : null;
3070 if (is_array($data)) {
3071 return $data;
3072 }
3073 return $_POST;
3074 }
3075
3076 private function ensureCmsShopMediaFolder(): void {
3077 $dir = rtrim((string)dbx()->get_file_dir(), '/\\') . '/media/img/shop';
3078 if (!is_dir($dir)) {
3079 @mkdir($dir, 0775, true);
3080 }
3081 }
3082
3083 private function jsonExit(array $data): string {
3084 if (!headers_sent()) {
3085 header('Content-Type: application/json; charset=utf-8');
3086 }
3087 echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
3088 exit;
3089 }
3090
3091 private function assignMedia(): string {
3092 if (!$this->checkActionToken('assign_media')) {
3093 return $this->jsonExit(array('ok' => 0, 'msg' => $this->postedFormError));
3094 }
3095 $payload = $this->readJsonPayload();
3096 $productId = (int)($payload['product_id'] ?? 0);
3097 $groupId = (int)($payload['group_id'] ?? 0);
3098 $mediaId = (int)($payload['media_id'] ?? $payload['id'] ?? 0);
3099 if ($mediaId <= 0 || ($productId <= 0 && $groupId <= 0)) {
3100 return $this->jsonExit(array('ok' => 0, 'msg' => 'Bitte Artikel oder Artikelgruppe und ein Medium auswaehlen.'));
3101 }
3102
3103 $row = $this->repo()->saveMediaImage(
3104 $productId,
3105 $groupId,
3106 $mediaId,
3107 (string)($payload['title'] ?? ''),
3108 (string)($payload['alt'] ?? $payload['title'] ?? ''),
3109 !empty($payload['is_primary']) ? 1 : 0,
3110 (int)($payload['sorter'] ?? 100)
3111 );
3112 if (!$row) {
3113 return $this->jsonExit(array('ok' => 0, 'msg' => 'Bild konnte nicht zugeordnet werden.'));
3114 }
3115 $this->syncShopMediaUsage();
3116 return $this->jsonExit(array(
3117 'ok' => 1,
3118 'image' => $row,
3119 'url' => $this->mediaItemUrl($row, true),
3120 ));
3121 }
3122
3123 private function productTreeMove(): string {
3124 if (!$this->checkActionToken('product_tree_move')) {
3125 return $this->jsonExit(array('ok' => 0, 'msg' => $this->postedFormError));
3126 }
3127 $this->ensureSeed();
3128 $payload = $this->readJsonPayload();
3129 $type = (string)($payload['type'] ?? '');
3130 $targetGroupId = (int)($payload['target_group_id'] ?? 0);
3131
3132 if ($type === 'product') {
3133 $productId = (int)($payload['product_id'] ?? 0);
3134 if ($productId <= 0 || $targetGroupId <= 0) {
3135 return $this->jsonExit(array('ok' => 0, 'msg' => 'Artikel und Zielgruppe sind erforderlich.'));
3136 }
3137 $count = $this->repo()->setProductGroupForProducts(array($productId), $targetGroupId);
3138 if ($count <= 0) {
3139 return $this->jsonExit(array('ok' => 0, 'msg' => 'Artikel konnte nicht verschoben werden.'));
3140 }
3141 return $this->jsonExit(array('ok' => 1, 'msg' => 'Artikelgruppe wurde zugeordnet.'));
3142 }
3143
3144 if ($type === 'group') {
3145 $groupId = (int)($payload['group_id'] ?? 0);
3146 if ($groupId <= 0) {
3147 return $this->jsonExit(array('ok' => 0, 'msg' => 'Artikelgruppe ist erforderlich.'));
3148 }
3149 if (!$this->repo()->moveProductGroupParent($groupId, $targetGroupId)) {
3150 return $this->jsonExit(array('ok' => 0, 'msg' => 'Artikelgruppe konnte nicht verschoben werden. Pruefen Sie, ob dadurch ein Kreis entstehen wuerde.'));
3151 }
3152 return $this->jsonExit(array('ok' => 1, 'msg' => 'Artikelgruppe wurde verschoben.'));
3153 }
3154
3155 return $this->jsonExit(array('ok' => 0, 'msg' => 'Unbekannte Drag-Drop-Aktion.'));
3156 }
3157
3158 private function dashboard(): string {
3159 $stats = $this->repo()->dashboardStats();
3160 return $this->frame($this->tpl()->get_tpl('dbxShop_admin|admin-dashboard', array(
3161 'orders_open' => (string)($stats['orders_open'] ?? 0),
3162 'payments_open' => (string)($stats['payments_open'] ?? 0),
3163 'shipping_open' => (string)($stats['shipping_open'] ?? 0),
3164 'withdrawals_open' => (string)($stats['withdrawals_open'] ?? 0),
3165 'stock_low' => (string)($stats['stock_low'] ?? 0),
3166 'products_active' => (string)($stats['products_active'] ?? 0),
3167 'product_url' => '?dbx_modul=dbxShop_admin&dbx_run1=products',
3168 'order_url' => '?dbx_modul=dbxShop_admin&dbx_run1=orders',
3169 'group_url' => '?dbx_modul=dbxShop_admin&dbx_run1=groups',
3170 'attribute_url' => '?dbx_modul=dbxShop_admin&dbx_run1=attributes',
3171 'shipping_group_url' => '?dbx_modul=dbxShop_admin&dbx_run1=shipping_groups',
3172 'channel_group_url' => '?dbx_modul=dbxShop_admin&dbx_run1=channel_groups',
3173 'channel_url' => '?dbx_modul=dbxShop_admin&dbx_run1=channels',
3174 'media_url' => '?dbx_modul=dbxShop_admin&dbx_run1=media',
3175 'legal_url' => '?dbx_modul=dbxShop_admin&dbx_run1=legal',
3176 'return_url' => '?dbx_modul=dbxShop_admin&dbx_run1=returns',
3177 'install_url' => $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=install'),
3178 )));
3179 }
3180
3181 private function placeholder(string $title, string $text): string {
3182 return $this->frame(
3183 '<div class="alert alert-info m-3"><strong>' . $this->h($title) . '</strong><br>' . $this->h($text) . '</div>',
3184 $title
3185 );
3186 }
3187
3188 private function shopLegalCmsPage(string $key, string $title, string $intro, string $shopRun): string {
3189 $service = dbx()->get_include_obj('dbxShopService', 'dbxShop');
3190 $pages = is_object($service) && method_exists($service, 'ensureShopLegalPages')
3191 ? $service->ensureShopLegalPages()
3192 : array();
3193 $cid = (int)($pages[$key] ?? 0);
3194 if ($cid <= 0) {
3195 return $this->placeholder($title, 'Die CMS-Seite konnte nicht angelegt oder gefunden werden.');
3196 }
3197
3198 $row = $this->db()->select1($this->contentDd(), $cid, 'id,title,permalink,content,group_read,template,activ', 0);
3199 if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) {
3200 return $this->placeholder($title, 'Die CMS-Seite konnte nicht geladen werden.');
3201 }
3202
3203 $editUrl = '?dbx_modul=dbxContent_admin&dbx_run1=cms&cid=' . $cid;
3204 $cmsViewUrl = '?dbx_modul=dbxContent&dbx_run1=content&cid=' . $cid;
3205 $shopViewUrl = '?dbx_modul=dbxShop&dbx_run1=' . rawurlencode($shopRun);
3206 $permalink = (string)($row['permalink'] ?? '');
3207
3208 $actions = $this->openWinButton($editUrl, $title . ' bearbeiten', '<i class="bi bi-pencil-square"></i><span> Bearbeiten</span>', 'btn btn-primary btn-sm me-1', '94%', '92%')
3209 . $this->openWinButton($cmsViewUrl, $title . ' CMS-Ansicht', '<i class="bi bi-file-richtext"></i><span class="visually-hidden"> CMS-Ansicht</span>', 'btn btn-outline-primary btn-sm me-1', '82%', '86%')
3210 . $this->openWinButton($shopViewUrl, $title . ' im Shop ansehen', '<i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden"> Shop-Ansicht</span>', 'btn btn-outline-primary btn-sm me-1', '82%', '86%');
3211
3212 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
3213 $preview = is_object($renderer) && method_exists($renderer, 'renderStatic')
3214 ? (string)$renderer->renderStatic($cid, array('template' => 'c-body1-footer', 'skip_hits' => true))
3215 : trim((string)($row['content'] ?? ''));
3216 if (trim($preview) === '') {
3217 $preview = '<div class="alert alert-warning">Die CMS-Seite ist leer.</div>';
3218 }
3219
3220 $meta = '<dl class="dbx-shop-admin-cms-meta">'
3221 . '<dt>CMS-ID</dt><dd>' . $cid . '</dd>'
3222 . '<dt>Permalink</dt><dd><code>' . $this->h($permalink) . '</code></dd>'
3223 . '<dt>Status</dt><dd>' . ((int)($row['activ'] ?? 0) === 1 ? 'Aktiv' : 'Inaktiv') . '</dd>'
3224 . '<dt>Leserechte</dt><dd><code>' . $this->h((string)($row['group_read'] ?? '')) . '</code></dd>'
3225 . '<dt>Template</dt><dd><code>' . $this->h((string)($row['template'] ?? '')) . '</code></dd>'
3226 . '</dl>';
3227
3228 $html = '<section class="dbx-shop-admin-cms-page">'
3229 . '<div class="alert alert-info"><strong>' . $this->h($title) . '</strong><br>' . $this->h($intro) . '</div>'
3230 . $meta
3231 . '<div class="dbx-shop-admin-cms-preview">' . $preview . '</div>'
3232 . '</section>';
3233
3234 return $this->frame($html, $title, $actions);
3235 }
3236
3237 private function settingsBool(array $cfg, string $key, bool $default = false): bool {
3238 if (!array_key_exists($key, $cfg)) {
3239 return $default;
3240 }
3241 $value = $cfg[$key];
3242 if (is_bool($value)) return $value;
3243 return in_array(strtolower(trim((string)$value)), array('1', 'true', 'yes', 'on'), true);
3244 }
3245
3246 private function saveSettings(): void {
3247 $cfg = $this->shopConfig();
3248 $cfg['enabled'] = !empty($_POST['enabled']);
3249 $cfg['activ'] = $cfg['enabled'] ? '1' : '0';
3250 $cfg['default_channel'] = trim((string)($_POST['default_channel'] ?? 'shop')) ?: 'shop';
3251 $cfg['default_currency'] = strtoupper(substr(preg_replace('~[^A-Z]~i', '', (string)($_POST['default_currency'] ?? 'EUR')) ?: 'EUR', 0, 3));
3252 $cfg['price_display'] = in_array((string)($_POST['price_display'] ?? 'gross'), array('gross', 'net'), true) ? (string)$_POST['price_display'] : 'gross';
3253 $cfg['default_tax_class'] = in_array((string)($_POST['default_tax_class'] ?? 'mwst1'), array('mwst1', 'mwst2', 'mwst3'), true) ? (string)$_POST['default_tax_class'] : 'mwst1';
3254 $cfg['tax_display_enabled'] = (string)($_POST['tax_display_enabled'] ?? '1') !== '0';
3255
3256 $rates = array();
3257 foreach (array('mwst1', 'mwst2', 'mwst3') as $key) {
3258 $title = trim((string)($_POST['tax_title_' . $key] ?? $key));
3259 $rate = str_replace(',', '.', trim((string)($_POST['tax_rate_' . $key] ?? '0')));
3260 $rates[$key] = array(
3261 'title' => $title !== '' ? $title : $key,
3262 'rate' => number_format((float)$rate, 2, '.', ''),
3263 );
3264 }
3265 $cfg['tax_rates'] = $rates;
3266
3267 foreach (array(
3268 'b2b_mode',
3269 'stock_enabled',
3270 'channels_enabled',
3271 'checkout_guest_allowed',
3272 'demo_notice_enabled',
3273 'legal_snapshot_enabled',
3274 'withdrawal_button_enabled',
3275 'mail_customer_enabled',
3276 'mail_admin_enabled',
3277 'payment_bank_transfer_enabled',
3278 'payment_invoice_enabled',
3279 'payment_paypal_enabled',
3280 'payment_amazon_pay_enabled',
3281 'delivery_digital_download_enabled',
3282 'delivery_flat_shipping_enabled',
3283 ) as $key) {
3284 $cfg[$key] = !empty($_POST[$key]);
3285 }
3286
3287 $cfg['payment_bank_transfer_account_owner'] = trim((string)($_POST['payment_bank_transfer_account_owner'] ?? ''));
3288 $cfg['payment_bank_transfer_iban'] = trim((string)($_POST['payment_bank_transfer_iban'] ?? ''));
3289 $cfg['payment_bank_transfer_bic'] = trim((string)($_POST['payment_bank_transfer_bic'] ?? ''));
3290 $cfg['payment_bank_transfer_bank_name'] = trim((string)($_POST['payment_bank_transfer_bank_name'] ?? ''));
3291 $cfg['payment_bank_transfer_instructions'] = trim((string)($_POST['payment_bank_transfer_instructions'] ?? ''));
3292 $cfg['payment_invoice_instructions'] = trim((string)($_POST['payment_invoice_instructions'] ?? ''));
3293 $cfg['payment_paypal_mode'] = (string)($_POST['payment_paypal_mode'] ?? 'sandbox') === 'live' ? 'live' : 'sandbox';
3294 $cfg['payment_paypal_client_id'] = trim((string)($_POST['payment_paypal_client_id'] ?? ''));
3295 $cfg['payment_paypal_client_secret'] = trim((string)($_POST['payment_paypal_client_secret'] ?? ''));
3296 $cfg['payment_paypal_brand_name'] = trim((string)($_POST['payment_paypal_brand_name'] ?? 'dbXapp')) ?: 'dbXapp';
3297 $cfg['payment_paypal_currency'] = $cfg['default_currency'];
3298 $cfg['payment_amazon_pay_mode'] = (string)($_POST['payment_amazon_pay_mode'] ?? 'sandbox') === 'live' ? 'live' : 'sandbox';
3299 $cfg['payment_amazon_pay_region'] = in_array((string)($_POST['payment_amazon_pay_region'] ?? 'EU'), array('EU', 'UK', 'US'), true) ? (string)$_POST['payment_amazon_pay_region'] : 'EU';
3300 $cfg['payment_amazon_pay_merchant_id'] = trim((string)($_POST['payment_amazon_pay_merchant_id'] ?? ''));
3301 $cfg['payment_amazon_pay_store_id'] = trim((string)($_POST['payment_amazon_pay_store_id'] ?? ''));
3302 $cfg['payment_amazon_pay_public_key_id'] = trim((string)($_POST['payment_amazon_pay_public_key_id'] ?? ''));
3303 $cfg['payment_amazon_pay_private_key'] = trim((string)($_POST['payment_amazon_pay_private_key'] ?? ''));
3304 $cfg['payment_amazon_pay_sandbox_simulation_code'] = trim((string)($_POST['payment_amazon_pay_sandbox_simulation_code'] ?? ''));
3305 $cfg['mail_from'] = trim((string)($_POST['mail_from'] ?? ''));
3306 $cfg['mail_admin_to'] = trim((string)($_POST['mail_admin_to'] ?? ''));
3307
3308 $flatShipping = str_replace(',', '.', trim((string)($_POST['delivery_flat_shipping_gross_price'] ?? '0')));
3309 $cfg['delivery_flat_shipping_gross_price'] = number_format((float)$flatShipping, 2, '.', '');
3310 $cfg['media_usage_slot'] = preg_replace('~[^a-z0-9_-]+~i', '', (string)($_POST['media_usage_slot'] ?? 'shop')) ?: 'shop';
3311
3312 $mailLocal = array(
3313 'mail_profile' => trim((string)($cfg['mail_profile'] ?? 'dbxApp')) ?: 'dbxApp',
3314 'mail_from' => $cfg['mail_from'],
3315 'mail_from_name' => trim((string)($cfg['mail_from_name'] ?? 'dbxShop')) ?: 'dbxShop',
3316 'mail_admin_to' => $cfg['mail_admin_to'],
3317 );
3318 dbx()->patch_local_config('dbxShop', $mailLocal);
3319 dbx()->set_config('dbxShop', $cfg);
3320 $this->loadContentCacheSupport();
3321 if (class_exists('\\dbx\\dbxContent\\dbxContentPageCache')) {
3323 }
3324 }
3325
3326 private function settingsFormData(array $cfg): array {
3327 $rates = $this->taxRatesConfig();
3328 $data = $cfg;
3329 foreach (array('mwst1', 'mwst2', 'mwst3') as $key) {
3330 $rate = is_array($rates[$key] ?? null) ? $rates[$key] : array();
3331 $data['tax_title_' . $key] = (string)($rate['title'] ?? $key);
3332 $data['tax_rate_' . $key] = (string)($rate['rate'] ?? '0');
3333 }
3334
3335 foreach (array(
3336 'enabled',
3337 'b2b_mode',
3338 'stock_enabled',
3339 'channels_enabled',
3340 'checkout_guest_allowed',
3341 'demo_notice_enabled',
3342 'legal_snapshot_enabled',
3343 'withdrawal_button_enabled',
3344 'mail_customer_enabled',
3345 'mail_admin_enabled',
3346 'payment_bank_transfer_enabled',
3347 'payment_invoice_enabled',
3348 'payment_paypal_enabled',
3349 'payment_amazon_pay_enabled',
3350 'delivery_digital_download_enabled',
3351 'delivery_flat_shipping_enabled',
3352 ) as $key) {
3353 $data[$key] = $this->settingsBool($cfg, $key, in_array($key, array(
3354 'enabled',
3355 'checkout_guest_allowed',
3356 'demo_notice_enabled',
3357 'legal_snapshot_enabled',
3358 'withdrawal_button_enabled',
3359 'mail_customer_enabled',
3360 'mail_admin_enabled',
3361 'payment_bank_transfer_enabled',
3362 'delivery_digital_download_enabled',
3363 'delivery_flat_shipping_enabled',
3364 ), true)) ? 1 : 0;
3365 }
3366
3367 $data['default_channel'] = (string)($cfg['default_channel'] ?? 'shop');
3368 $data['channels_enabled'] = array_key_exists('channels_enabled', $cfg) ? (int)((bool)$cfg['channels_enabled']) : 1;
3369 $data['default_currency'] = (string)($cfg['default_currency'] ?? 'EUR');
3370 $data['price_display'] = (string)($cfg['price_display'] ?? 'gross');
3371 $data['default_tax_class'] = (string)($cfg['default_tax_class'] ?? 'mwst1');
3372 $data['tax_display_enabled'] = $this->settingsBool($cfg, 'tax_display_enabled', true) ? 1 : 0;
3373 $data['payment_paypal_mode'] = (string)($cfg['payment_paypal_mode'] ?? 'sandbox');
3374 $data['payment_paypal_brand_name'] = (string)($cfg['payment_paypal_brand_name'] ?? 'dbXapp');
3375 $data['payment_paypal_client_id'] = (string)($cfg['payment_paypal_client_id'] ?? '');
3376 $data['payment_paypal_client_secret'] = (string)($cfg['payment_paypal_client_secret'] ?? '');
3377 $data['payment_bank_transfer_account_owner'] = (string)($cfg['payment_bank_transfer_account_owner'] ?? '');
3378 $data['payment_bank_transfer_iban'] = (string)($cfg['payment_bank_transfer_iban'] ?? '');
3379 $data['payment_bank_transfer_bic'] = (string)($cfg['payment_bank_transfer_bic'] ?? '');
3380 $data['payment_bank_transfer_bank_name'] = (string)($cfg['payment_bank_transfer_bank_name'] ?? '');
3381 $data['payment_bank_transfer_instructions'] = (string)($cfg['payment_bank_transfer_instructions'] ?? 'Bitte ueberweisen Sie den Rechnungsbetrag unter Angabe der Bestellnummer.');
3382 $data['payment_invoice_instructions'] = (string)($cfg['payment_invoice_instructions'] ?? 'Sie erhalten eine Rechnung. Bitte zahlen Sie innerhalb der angegebenen Frist.');
3383 $data['payment_amazon_pay_mode'] = (string)($cfg['payment_amazon_pay_mode'] ?? 'sandbox');
3384 $data['payment_amazon_pay_region'] = (string)($cfg['payment_amazon_pay_region'] ?? 'EU');
3385 $data['payment_amazon_pay_merchant_id'] = (string)($cfg['payment_amazon_pay_merchant_id'] ?? '');
3386 $data['payment_amazon_pay_store_id'] = (string)($cfg['payment_amazon_pay_store_id'] ?? '');
3387 $data['payment_amazon_pay_public_key_id'] = (string)($cfg['payment_amazon_pay_public_key_id'] ?? '');
3388 $data['payment_amazon_pay_private_key'] = (string)($cfg['payment_amazon_pay_private_key'] ?? '');
3389 $data['payment_amazon_pay_sandbox_simulation_code'] = (string)($cfg['payment_amazon_pay_sandbox_simulation_code'] ?? '');
3390 $data['mail_from'] = (string)($cfg['mail_from'] ?? '');
3391 $data['mail_admin_to'] = (string)($cfg['mail_admin_to'] ?? '');
3392 $data['delivery_flat_shipping_gross_price'] = (string)($cfg['delivery_flat_shipping_gross_price'] ?? '5.90');
3393 $data['media_usage_slot'] = (string)($cfg['media_usage_slot'] ?? 'shop');
3394
3395 return $data;
3396 }
3397
3398 private function settingsChannelsStatusHtml(array $cfg, $texts): string {
3399 $channelsEnabled = $this->settingsBool($cfg, 'channels_enabled', true);
3400 $channels = $this->repo()->channels();
3401 $external = array();
3402 foreach ($channels as $channel) {
3403 $key = strtolower(trim((string)($channel['channel_key'] ?? '')));
3404 if ($key === '' || $key === 'shop') {
3405 continue;
3406 }
3407 if ((int)($channel['active'] ?? 0) !== 1) {
3408 continue;
3409 }
3410 $external[] = $channel;
3411 }
3412
3413 $html = '<div class="dbx-shop-settings-channel-status">';
3414 $html .= '<div class="dbx-shop-settings-channel-head">';
3415 $html .= '<div><strong>' . $this->h($texts->get_fd_message('channels_external')) . '</strong><span>' . $this->h($channelsEnabled ? $texts->get_fd_message('channels_global_active') : $texts->get_fd_message('channels_global_inactive')) . '</span></div>';
3416 $html .= $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=channels', $texts->get_fd_message('channels_edit'), '<i class="bi bi-broadcast"></i> ' . $this->h($texts->get_fd_message('column_channel')), 'btn btn-outline-primary btn-sm', '92%', '88%');
3417 $html .= '</div>';
3418
3419 if (!$channelsEnabled) {
3420 $html .= '<div class="alert alert-warning py-2 mb-0">' . $this->h($texts->get_fd_message('channels_disabled')) . '</div>';
3421 }
3422
3423 if ($external === array()) {
3424 $html .= '<div class="dbx-shop-settings-channel-empty">' . $this->h($texts->get_fd_message('channels_none')) . '</div>';
3425 return $html . '</div>';
3426 }
3427
3428 $html .= '<div class="dbx-shop-settings-channel-grid">';
3429 $html .= '<div class="dbx-shop-settings-channel-grid-head"><span>' . $this->h($texts->get_fd_message('column_channel')) . '</span><span>' . $this->h($texts->get_fd_message('column_platform')) . '</span><span>' . $this->h($texts->get_fd_message('column_connection')) . '</span><span>' . $this->h($texts->get_fd_message('column_export')) . '</span><span>' . $this->h($texts->get_fd_message('column_import')) . '</span><span>' . $this->h($texts->get_fd_message('column_test')) . '</span></div>';
3430 foreach ($external as $channel) {
3431 $test = trim((string)($channel['test_status'] ?? ''));
3432 $testClass = $test === 'ok' ? 'success' : ($test !== '' ? 'warning' : 'secondary');
3433 $html .= '<div class="dbx-shop-settings-channel-grid-row">';
3434 $html .= '<span class="dbx-shop-settings-channel-name"><strong>' . $this->h((string)($channel['title'] ?? $channel['channel_key'] ?? '')) . '</strong><code>' . $this->h((string)($channel['channel_key'] ?? '')) . '</code></span>';
3435 $html .= '<span>' . $this->h((string)($channel['platform_type'] ?? '')) . '</span>';
3436 $html .= '<span>' . $this->h((string)($channel['connection_mode'] ?? '')) . '</span>';
3437 $html .= ((int)($channel['export_enabled'] ?? 0) === 1 ? '<span class="badge text-bg-success">' . $this->h($texts->get_fd_message('column_export')) . '</span>' : '<span class="badge text-bg-secondary">' . $this->h($texts->get_fd_message('export_off')) . '</span>');
3438 $html .= ((int)($channel['order_import_enabled'] ?? 0) === 1 ? '<span class="badge text-bg-primary">' . $this->h($texts->get_fd_message('column_import')) . '</span>' : '<span class="badge text-bg-secondary">' . $this->h($texts->get_fd_message('import_off')) . '</span>');
3439 $html .= '<span class="badge text-bg-' . $testClass . '">' . $this->h($test !== '' ? $test : $texts->get_fd_message('not_tested')) . '</span>';
3440 $html .= '</div>';
3441 }
3442 $html .= '</div>';
3443 return $html . '</div>';
3444 }
3445
3446 private function settings(): string {
3447 $form = dbx()->get_system_obj('dbxForm');
3448 $form->init('shop-settings-form', 'shop-settings-form');
3449 $form->_fd = 'dbxShop_admin|shop-settings';
3450 $form->load_fd_messages();
3451 $helpId = $this->ensureShopSettingsHelpPage();
3452 $helpButton = $helpId > 0
3453 ? $this->openWinButton('?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId, $form->get_fd_message('settings_help'), '<i class="bi bi-question-circle"></i><span class="visually-hidden"> ' . $this->h($form->get_fd_message('settings_help')) . '</span>', 'btn btn-outline-secondary btn-sm me-1', '72%', '82%')
3454 : '';
3455 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=settings';
3456 $form->_data = $this->settingsFormData($this->shopConfig());
3457 $form->add_rep('shop_admin_style', $this->shopAdminStyle());
3458 $form->add_rep('form_class', 'dbx-shop-settings-dbXForm');
3459 $form->add_rep('bar_title', $this->h($form->get_fd_message('settings_title')));
3460 $form->add_rep('bar_icon', 'bi-sliders');
3461 $form->add_rep('bar_subtitle', $this->h($form->get_fd_message('settings_subtitle')));
3462 $form->add_rep('bar_class', 'dbx-module-bar');
3463 $form->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
3464 $form->add_rep('bar_title_pre', '');
3465 $form->add_rep('bar_title_heading_attrs', '');
3466 $form->add_rep('bar_middle', '');
3467 $form->add_rep('bar_actions_class', 'dbx-module-bar-actions');
3468 $form->add_rep('bar_extra', '');
3469 $form->add_obj('channels_status', 'obj-value', $this->settingsChannelsStatusHtml($this->shopConfig(), $form));
3470 $paymentTestButton = $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=payment_test', $form->get_fd_message('settings_payment_test'), '<i class="bi bi-plug"></i><span class="visually-hidden"> ' . $this->h($form->get_fd_message('settings_payment_test')) . '</span>', 'btn btn-outline-primary btn-sm me-1', '64%', '58%');
3471 $form->add_rep('bar_actions', $paymentTestButton . '<button class="btn btn-primary btn-sm" type="submit" name="shop_action" value="save_settings" title="' . $this->h($form->get_fd_message('settings_save')) . '"><i class="bi bi-save"></i><span class="visually-hidden"> ' . $this->h($form->get_fd_message('settings_save')) . '</span></button>' . $helpButton);
3472 $form->_msg_info = '';
3473 $form->add_flds();
3474
3475 if ($form->submit()) {
3476 if (!$form->errors() && !$form->warnings()) {
3477 $this->saveSettings();
3478 $form->_data = $this->settingsFormData($this->shopConfig());
3479 $form->add_obj('channels_status', 'obj-value', $this->settingsChannelsStatusHtml($this->shopConfig(), $form));
3480 }
3481 }
3482
3483 return $form->run();
3484 }
3485
3486 private function paymentTest(): string {
3487 $paypal = dbx()->get_include_obj('dbxShopPayPal', 'dbxShop');
3488 $amazonPay = dbx()->get_include_obj('dbxShopAmazonPay', 'dbxShop');
3489 $paypalResult = is_object($paypal) && method_exists($paypal, 'testConnection')
3490 ? $paypal->testConnection()
3491 : array('ok' => false, 'mode' => '', 'message' => 'PayPal-Connector konnte nicht geladen werden.');
3492 $amazonResult = is_object($amazonPay) && method_exists($amazonPay, 'testConnection')
3493 ? $amazonPay->testConnection()
3494 : array('ok' => false, 'mode' => '', 'region' => '', 'message' => 'Amazon-Pay-Connector konnte nicht geladen werden.');
3495
3496 $card = function(string $title, string $icon, array $result): string {
3497 $ok = !empty($result['ok']);
3498 $meta = array();
3499 if (trim((string)($result['mode'] ?? '')) !== '') {
3500 $meta[] = 'Modus: ' . $this->h((string)$result['mode']);
3501 }
3502 if (trim((string)($result['region'] ?? '')) !== '') {
3503 $meta[] = 'Region: ' . $this->h((string)$result['region']);
3504 }
3505 return $this->tpl()->get_tpl('dbxShop_admin|payment-test-card', array(
3506 'icon' => $this->h($icon),
3507 'title' => $this->h($title),
3508 'badge_class' => $ok ? 'text-bg-success' : 'text-bg-warning',
3509 'badge_text' => $ok ? 'OK' : 'Pruefen',
3510 'meta' => $meta !== array() ? '<p class="dbx-shop-payment-test-meta">' . implode(' · ', $meta) . '</p>' : '',
3511 'message' => $this->h((string)($result['message'] ?? 'Keine Rueckmeldung.')),
3512 ));
3513 };
3514
3515 $body = $this->tpl()->get_tpl('dbxShop_admin|payment-test', array(
3516 'cards' => $card('PayPal', 'bi-paypal', $paypalResult)
3517 . $card('Amazon Pay', 'bi-amazon', $amazonResult),
3518 ));
3519 return $this->frame($body, 'Payment testen');
3520 }
3521
3522 private function install(): string {
3523 if (!$this->checkActionToken('install')) {
3524 return $this->placeholder('Shop-Installation abgewiesen', $this->postedFormError);
3525 }
3526 $this->maintenanceMode = true;
3527 try {
3528 $this->repo()->seedDemoProducts();
3529 $this->maintainShopAdminContent();
3530 } finally {
3531 $this->maintenanceMode = false;
3532 }
3533 return $this->placeholder(
3534 'Shop-Installation ausgefuehrt',
3535 'dbxShop.db3 wurde angelegt bzw. aktualisiert. Deutsche Testartikel, Gruppen und Channel-Zuordnungen sind vorhanden.'
3536 );
3537 }
3538
3539 private function products(): string {
3540 $this->ensureSeed();
3541 $report = dbx()->get_system_obj('dbxReport');
3542 $report->init('shop-products-report');
3543 $report->_fd = 'dbxShop_admin|rpt-products-selection';
3544 $report->load_fd_messages();
3545 // dbxReport haengt die konkreten Schreibaktionen an diese Basis-URL an.
3546 // Der zusaetzliche Token ist fuer reine Filter-/Navigationsaufrufe
3547 // unschaedlich und sichert gleichzeitig die kompatiblen GET-Aktionen.
3548 $report->_action = $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=products');
3549 $report->_mode = 'table';
3550 $report->_pages = true;
3551 $report->_multi_page_select = 1;
3552 $report->_create_row_select = true;
3553 $report->_create_row_delete = true;
3554 $report->_create_row_edit = true;
3555 $report->_create_row_show = true;
3556 $report->_create_sel_flds = true;
3557 $report->_but_pagination = 7;
3558 $report->_fld_id = 'id';
3559 $report->_msg_confirm_delete = $report->get_fd_message('delete_confirm');
3560 $report->_msg_info = $report->get_fd_message('report_info');
3561 $report->add_rep('bar_title', $report->get_fd_message('bar_title'));
3562 $report->add_rep('bar_icon', 'bi-bag-check');
3563 $report->add_rep('bar_subtitle', $report->get_fd_message('bar_subtitle'));
3564 $report->add_rep('bar_class', 'dbx-module-bar');
3565 $report->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
3566 $report->add_rep('bar_actions_class', 'dbx-module-bar-actions');
3567 $report->add_rep('bar_title_pre', $this->productTreeToggleButton($report));
3568 $report->add_rep('bar_title_heading_attrs', '');
3569 $report->add_rep('bar_middle', '');
3570 $report->add_rep('bar_extra', '');
3571 $report->add_rep('bar_actions', $this->productShellActions($report));
3572 $report->add_rep('shop_admin_style', $this->shopAdminStyle());
3573 $report->add_rep('report_form_class', 'dbx-shop-products-form is-shop-tree-collapsed');
3574 $report->add_rep('report_form_attrs', 'data-dbx="lib=shopAdmin" data-shop-tree-shell');
3575 $channelsEnabled = $this->channelsEnabled();
3576 $columnClasses = array(
3577 'image_view' => 'dbx-shop-col-image',
3578 'article_view' => 'dbx-shop-col-article',
3579 'groups_view' => 'dbx-shop-col-groups',
3580 'attributes_view' => 'dbx-shop-col-attributes',
3581 'shipping_groups_view' => 'dbx-shop-col-shipping-groups',
3582 'price_view' => 'dbx-shop-col-money',
3583 'tax_view' => 'dbx-shop-col-tax',
3584 'shipping_view' => 'dbx-shop-col-money',
3585 'status_view' => 'dbx-shop-col-status',
3586 );
3587 if ($channelsEnabled) {
3588 $columnClasses['channel_groups_view'] = 'dbx-shop-col-channel-groups';
3589 $columnClasses['channels_view'] = 'dbx-shop-col-channels';
3590 }
3591 foreach ($columnClasses as $field => $class) {
3592 $report->set_class_haeder($field, $class);
3593 $report->_class_body[$field] = $class;
3594 }
3595 $report->set_callback_owner($this);
3596 $report->set_callback('next_record', 'product_report_next_record');
3597 $report->set_callback('row_action_data', 'product_report_row_action_data');
3598 $report->add_rep('report_products_actions', $this->productReportActionControls($report->_action, $report));
3599 $report->create_selection_fields('dbxShop_admin|rpt-products-selection');
3600 $this->handleProductReportAction($report);
3601
3602 $query = trim((string)$report->get_fld_val('dbx_rwhere', '', 'parameter|max=100'));
3603 $requestedRowsPerPage = (int)$report->get_fld_val('dbx_rrows', 30, 'int');
3604 $rowsPerPage = $requestedRowsPerPage === 0 ? 0 : max(10, min(100, $requestedRowsPerPage));
3605 $position = $rowsPerPage === 0 ? 0 : max(0, (int)$report->get_fld_val('dbx_rpos', 0, 'int'));
3606 $sort = (string)$report->get_fld_val('dbx_rsort', 'sorter', 'parameter');
3607 $direction = strtoupper((string)$report->get_fld_val('dbx_rdesc', 'ASC', 'parameter')) === 'DESC' ? 'DESC' : 'ASC';
3608 $selectedOnly = (int)$report->get_fld_val('dbx_rselect', 0, 'int') === 1;
3609 if (!in_array($sort, array('sorter', 'sku', 'title', 'price_gross', 'effective_tax_rate', 'effective_shipping_gross', 'active'), true)) {
3610 $sort = 'sorter';
3611 }
3612
3613 $allProducts = $this->repo()->products(false);
3614 $matchedProducts = array();
3615 $selectedIds = $selectedOnly ? $report->get_multi_selects() : array();
3616 foreach ($allProducts as $product) {
3617 if ($selectedOnly && !isset($selectedIds[(string)(int)($product['id'] ?? 0)])) {
3618 continue;
3619 }
3620 $score = $this->productSearchScore($product, $query);
3621 if ($score <= 0) {
3622 continue;
3623 }
3624 $product['_search_score'] = $score;
3625 $matchedProducts[] = $product;
3626 }
3627 $matchedProducts = $this->sortProductsForReport($matchedProducts, $query, $sort, $direction);
3628 $filteredCount = count($matchedProducts);
3629 if ($rowsPerPage > 0 && $position >= $filteredCount && $filteredCount > 0) {
3630 $position = max(0, (int)(floor(($filteredCount - 1) / $rowsPerPage) * $rowsPerPage));
3631 }
3632 $visibleProducts = $rowsPerPage === 0
3633 ? $matchedProducts
3634 : array_slice($matchedProducts, $position, $rowsPerPage);
3635
3636 $report->_rflds = array(
3637 'image_view' => $report->get_fd_message('column_image'),
3638 'article_view' => $report->get_fd_message('column_product'),
3639 'groups_view' => $report->get_fd_message('column_product_groups'),
3640 'attributes_view' => $report->get_fd_message('column_attributes'),
3641 'shipping_groups_view' => $report->get_fd_message('column_shipping_groups'),
3642 'price_view' => $report->get_fd_message('column_price'),
3643 'tax_view' => $report->get_fd_message('column_tax'),
3644 'shipping_view' => $report->get_fd_message('column_shipping'),
3645 'status_view' => $report->get_fd_message('column_status'),
3646 );
3647 if ($channelsEnabled) {
3648 $report->_rflds = array_slice($report->_rflds, 0, 5, true)
3649 + array(
3650 'channel_groups_view' => $report->get_fd_message('column_channel_groups'),
3651 'channels_view' => $report->get_fd_message('column_channels'),
3652 )
3653 + array_slice($report->_rflds, 5, null, true);
3654 }
3655 $report->_rpt_format = array(
3656 'image_view' => 'html',
3657 'article_view' => 'html',
3658 'groups_view' => 'html',
3659 'attributes_view' => 'html',
3660 'shipping_groups_view' => 'html',
3661 'price_view' => 'html',
3662 'tax_view' => 'html',
3663 'shipping_view' => 'html',
3664 'status_view' => 'html',
3665 );
3666 if ($channelsEnabled) {
3667 $report->_rpt_format['channel_groups_view'] = 'html';
3668 $report->_rpt_format['channels_view'] = 'html';
3669 }
3670 $report->_rrows = $rowsPerPage;
3671 $report->_rpos = $position;
3672 $report->_count_all = count($allProducts);
3673 $report->_rcount = $filteredCount;
3674 $report->_rdata = $visibleProducts;
3675
3676 $report->add_rep('product_tree_panel', $this->productTreePanel($allProducts, $report));
3677 $content = $report->run();
3678 if ($filteredCount === 0) {
3679 $content .= '<div class="alert alert-info mx-3">'
3680 . $this->h($report->get_fd_message('no_results'))
3681 . '</div>';
3682 }
3683
3684 return $content;
3685 }
3686
3687 private function productEdit(): string {
3688 $this->ensureSeed();
3689 $id = (int)dbx()->get_modul_var('id', 0, 'int');
3690 $isNew = $id <= 0;
3691 $exportNotice = '';
3692 $exportOk = false;
3693 $removeImageId = (int)dbx()->get_modul_var('remove_image', 0, 'int');
3694 if (!$isNew && $removeImageId > 0) {
3695 if ($this->checkActionToken('remove_image')) {
3696 $this->repo()->removeProductImageAssociation($removeImageId, $id);
3697 $this->syncShopMediaUsage();
3698 }
3699 }
3700 $exportChannel = trim((string)dbx()->get_modul_var('export_channel', '', 'parameter'));
3701 if (!$isNew && $exportChannel !== '') {
3702 if ($this->checkActionToken('export_channel')) {
3703 $result = $this->repo()->exportProductToChannel($id, $exportChannel);
3704 $exportOk = !empty($result['ok']);
3705 $exportNotice = (string)($result['message'] ?? '');
3706 } else {
3707 $exportNotice = $this->postedFormError;
3708 }
3709 }
3710 $data = $isNew ? $this->applyProductPreset($this->newProductDefaults()) : $this->repo()->productById($id);
3711
3712 if (!$isNew && !is_array($data)) {
3713 return $this->frame('<div class="alert alert-warning m-3">Artikel nicht gefunden.</div>', 'Artikel bearbeiten', $this->productBarActions());
3714 }
3715
3716 $form = dbx()->get_system_obj('dbxForm');
3717 $form->init('shop-product-form', 'shop-product-form');
3718 $form->_dd = 'dbxShop|shopProduct';
3719 $form->_fd = 'dbxShop|shop-product';
3720 $form->load_fd_messages();
3721 $form->_data = $data;
3722 $form->_rid = $isNew ? 0 : $id;
3723 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . ($isNew ? 0 : $id);
3724 $form->set_activ_id($isNew ? 0 : $id);
3725 $form->add_rep(
3726 'bar_title',
3727 $form->get_fd_message(
3728 $isNew ? 'form_new_title' : 'form_edit_title'
3729 )
3730 );
3731 $form->add_rep('bar_icon', 'bi-bag-check');
3732 $form->add_rep(
3733 'bar_subtitle',
3734 $isNew
3735 ? $form->get_fd_message('form_new_subtitle')
3736 : trim((string)($data['sku'] ?? '') . ' - ' . (string)($data['title'] ?? ''))
3737 );
3738 $form->add_rep('bar_class', 'dbx-module-bar');
3739 $form->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
3740 $form->add_rep('bar_title_pre', '');
3741 $form->add_rep('bar_title_heading_attrs', '');
3742 $form->add_rep('bar_middle', '');
3743 $form->add_rep('bar_actions_class', 'dbx-module-bar-actions');
3744 $form->add_rep('bar_actions', $this->productFormActions($id, $form));
3745 $form->add_rep('bar_extra', '');
3746 $form->add_rep('shop_admin_style', $this->shopAdminStyle());
3747 $form->_msg_info = $form->get_fd_message('form_info');
3748 if ($exportNotice !== '') {
3749 if ($exportOk) {
3750 $form->_msg_success = $exportNotice;
3751 } else {
3752 $form->_msg_error = $exportNotice;
3753 }
3754 }
3755 $form->add_flds();
3756 $form->add_fld(
3757 'product_group_id',
3758 tpl: 'select-single-label',
3759 label: $form->get_fd_message('field_product_group'),
3760 options: $this->productGroupOptions(0, false),
3761 rules: 'int'
3762 );
3763
3764 if ($form->submit()) {
3765 if (!$form->errors()) {
3766 $ok = $form->save_post('dbxShop|shopProduct', $isNew ? 'new' : $id, $this->productFormDefaults($id));
3767 if ($ok) {
3768 $savedId = (int)$form->_rid;
3769 $groupId = (int)($_POST['product_group_id'] ?? 0);
3770 if ($savedId > 0 && $groupId > 0) {
3771 $this->repo()->setProductGroupForProducts(array($savedId), $groupId);
3772 }
3773 if ($savedId > 0 && isset($_POST['product_channel_editor'])) {
3774 $this->repo()->saveProductChannelOverrides($savedId, (array)($_POST['product_channels'] ?? array()));
3775 }
3776 $form->_msg_success = $form->get_fd_message(
3777 'product_save_success'
3778 );
3779 if ($savedId > 0) {
3780 $id = $savedId;
3781 $isNew = false;
3782 $data = $this->repo()->productById($savedId) ?: $form->_data;
3783 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $savedId;
3784 }
3785 } else {
3786 $form->_msg_error = $form->get_fd_message(
3787 'product_save_error'
3788 );
3789 }
3790 } else {
3791 $form->_msg_error = $form->get_fd_message(
3792 'validation_error'
3793 );
3794 }
3795 }
3796
3797 $form->add_obj(
3798 'product_images',
3799 'obj-value',
3800 $this->productImagesPanel(
3801 is_array($data) ? $data : array(),
3802 $isNew,
3803 $form
3804 )
3805 );
3806 $form->add_obj(
3807 'product_channels',
3808 'obj-value',
3809 $this->productChannelsPanel(
3810 is_array($data) ? $data : array(),
3811 $isNew,
3812 $form
3813 )
3814 );
3815
3816 $content = $form->run();
3817 if (!$isNew) {
3818 // Eigene Upload-/Video-Formulare erst nach dem Artikel-Formular
3819 // einbetten, damit der Browser keine verschachtelte Form erzeugt.
3820 $content .= $this->shopMediaFormTemplates($this->shopMediaConfig());
3821 }
3822 return $content;
3823 }
3824
3825 private function productsHelp(): string {
3826 $helpId = $this->ensureShopProductsHelpPage();
3827 if ($helpId > 0) {
3828 return $this->frame('<div class="m-3">' . $this->productsHelpHtml() . '</div>', 'Produkte Hilfe', $this->productBarActions());
3829 }
3830 return $this->frame('<div class="alert alert-warning m-3">Hilfe konnte nicht angelegt werden.</div>', 'Produkte Hilfe', $this->productBarActions());
3831 }
3832
3833 private function orderStatusOptions($texts = null): array {
3834 return array(
3835 'new' => $texts ? $texts->get_fd_message('order_status_new', 'Neu') : 'Neu',
3836 'payment_pending' => $texts ? $texts->get_fd_message('order_status_payment_pending', 'Zahlung offen') : 'Zahlung offen',
3837 'paid' => $texts ? $texts->get_fd_message('order_status_paid', 'Bezahlt') : 'Bezahlt',
3838 'processing' => $texts ? $texts->get_fd_message('order_status_processing', 'In Bearbeitung') : 'In Bearbeitung',
3839 'shipped' => $texts ? $texts->get_fd_message('order_status_shipped', 'Versendet') : 'Versendet',
3840 'done' => $texts ? $texts->get_fd_message('order_status_done', 'Abgeschlossen') : 'Abgeschlossen',
3841 'cancelled' => $texts ? $texts->get_fd_message('order_status_cancelled', 'Storniert') : 'Storniert',
3842 );
3843 }
3844
3845 private function paymentStatusOptions($texts = null): array {
3846 return array(
3847 'open' => $texts ? $texts->get_fd_message('payment_status_open', 'Offen') : 'Offen',
3848 'created' => $texts ? $texts->get_fd_message('payment_status_created', 'Erstellt') : 'Erstellt',
3849 'pending' => $texts ? $texts->get_fd_message('payment_status_pending', 'In Prüfung') : 'In Prüfung',
3850 'completed' => $texts ? $texts->get_fd_message('payment_status_completed', 'Abgeschlossen') : 'Abgeschlossen',
3851 'paid' => $texts ? $texts->get_fd_message('payment_status_paid', 'Bezahlt') : 'Bezahlt',
3852 'failed' => $texts ? $texts->get_fd_message('payment_status_failed', 'Fehlgeschlagen') : 'Fehlgeschlagen',
3853 'cancelled' => $texts ? $texts->get_fd_message('payment_status_cancelled', 'Abgebrochen') : 'Abgebrochen',
3854 'refunded' => $texts ? $texts->get_fd_message('payment_status_refunded', 'Erstattet') : 'Erstattet',
3855 );
3856 }
3857
3858 private function shippingStatusOptions($texts = null): array {
3859 return array(
3860 'open' => $texts ? $texts->get_fd_message('shipping_status_open', 'Offen') : 'Offen',
3861 'ready' => $texts ? $texts->get_fd_message('shipping_status_ready', 'Bereit') : 'Bereit',
3862 'shipped' => $texts ? $texts->get_fd_message('shipping_status_shipped', 'Versendet') : 'Versendet',
3863 'delivered' => $texts ? $texts->get_fd_message('shipping_status_delivered', 'Zugestellt') : 'Zugestellt',
3864 'returned' => $texts ? $texts->get_fd_message('shipping_status_returned', 'Retoure') : 'Retoure',
3865 );
3866 }
3867
3868 private function orderStatusBadge(string $status, $texts = null): string {
3869 $labels = $this->orderStatusOptions($texts);
3870 $classes = array(
3871 'new' => 'text-bg-secondary',
3872 'payment_pending' => 'text-bg-warning',
3873 'paid' => 'text-bg-success',
3874 'processing' => 'text-bg-info',
3875 'shipped' => 'text-bg-primary',
3876 'done' => 'text-bg-success',
3877 'cancelled' => 'text-bg-danger',
3878 );
3879 return '<span class="badge ' . $this->h($classes[$status] ?? 'text-bg-secondary') . '">' . $this->h($labels[$status] ?? $status) . '</span>';
3880 }
3881
3882 private function paymentStatusBadge(string $status, $texts = null): string {
3883 $labels = $this->paymentStatusOptions($texts);
3884 $classes = array(
3885 'open' => 'text-bg-secondary',
3886 'created' => 'text-bg-info',
3887 'pending' => 'text-bg-warning',
3888 'completed' => 'text-bg-success',
3889 'paid' => 'text-bg-success',
3890 'failed' => 'text-bg-danger',
3891 'cancelled' => 'text-bg-danger',
3892 'refunded' => 'text-bg-dark',
3893 );
3894 return '<span class="badge ' . $this->h($classes[$status] ?? 'text-bg-secondary') . '">' . $this->h($labels[$status] ?? $status) . '</span>';
3895 }
3896
3897 private function shippingStatusBadge(string $status, $texts = null): string {
3898 $labels = $this->shippingStatusOptions($texts);
3899 $classes = array(
3900 'open' => 'text-bg-secondary',
3901 'ready' => 'text-bg-info',
3902 'shipped' => 'text-bg-primary',
3903 'delivered' => 'text-bg-success',
3904 'returned' => 'text-bg-warning',
3905 );
3906 return '<span class="badge ' . $this->h($classes[$status] ?? 'text-bg-secondary') . '">' . $this->h($labels[$status] ?? $status) . '</span>';
3907 }
3908
3909 private function channelLabel(string $channel): string {
3910 $labels = array(
3911 'shop' => 'Shop',
3912 'web' => 'Web',
3913 'amazon' => 'Amazon',
3914 'ebay' => 'eBay',
3915 'kleinanzeigen' => 'Kleinanzeigen',
3916 'mobile' => 'mobile.de',
3917 );
3918 return $labels[$channel] ?? $channel;
3919 }
3920
3921 private function paymentProviderLabel(string $provider, $texts = null): string {
3922 $labels = array(
3923 'bank_transfer' => $texts ? $texts->get_fd_message('payment_bank_transfer', 'Vorkasse / Überweisung') : 'Vorkasse / Überweisung',
3924 'invoice' => $texts ? $texts->get_fd_message('payment_invoice', 'Rechnung') : 'Rechnung',
3925 'paypal' => 'PayPal',
3926 'amazon_pay' => 'Amazon Pay',
3927 );
3928 return $labels[$provider] ?? $this->channelLabel($provider);
3929 }
3930
3931 private function channelBadge(string $channel): string {
3932 $class = in_array($channel, array('shop', 'web', ''), true) ? 'text-bg-secondary' : 'text-bg-info';
3933 $text = $channel === '' ? 'Shop' : $this->channelLabel($channel);
3934 return '<span class="badge ' . $class . '">' . $this->h($text) . '</span>';
3935 }
3936
3937 private function orderActions($texts = null): string {
3938 $helpId = $this->ensureShopOrdersHelpPage();
3939 $helpTitle = $texts
3940 ? $texts->get_fd_message('help_orders', 'Hilfe: Bestellungen')
3941 : 'Hilfe: Bestellungen';
3942 $helpLabel = $texts
3943 ? $texts->get_fd_message('help_label', 'Hilfe')
3944 : 'Hilfe';
3945 $helpButton = $helpId > 0
3946 ? $this->openWinButton('?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId, $helpTitle, '<i class="bi bi-question-circle"></i><span class="visually-hidden"> ' . $this->h($helpLabel) . '</span>', 'btn btn-outline-secondary btn-sm me-1', '72%', '82%')
3947 : '';
3948 return $helpButton;
3949 }
3950
3951 private function handleOrderAction($report): void {
3952 $deleteId = (int)dbx()->get_modul_var('delete_order', 0, 'int');
3953 if ($deleteId <= 0) {
3954 return;
3955 }
3956 if (!$this->checkActionToken('delete_order')) {
3957 $report->_msg_error = $report->get_fd_message('token_error');
3958 return;
3959 }
3960 if ($this->repo()->deleteOrder($deleteId)) {
3961 $report->_msg_success = $report->get_fd_message('delete_success');
3962 } else {
3963 $report->_msg_error = $report->get_fd_message('delete_error');
3964 }
3965 }
3966
3967 public function order_report_next_record($report, $record) {
3968 $id = (int)($record['id'] ?? 0);
3969 $orderNo = (string)($record['order_no'] ?? '');
3970 $created = (string)($record['create_date'] ?? '');
3971 $channel = (string)($record['channel_key'] ?? 'shop');
3972 $items = (array)($record['items'] ?? array());
3973 $itemLines = '';
3974 foreach ($items as $item) {
3975 $itemLines .= '<div>' . (int)($item['qty'] ?? 0) . 'x <strong>' . $this->h($item['title'] ?? '') . '</strong> <code>' . $this->h($item['sku'] ?? '') . '</code></div>';
3976 }
3977 if ($itemLines === '') {
3978 $itemLines = '<span class="text-muted">'
3979 . $this->h($report->get_fd_message('no_items'))
3980 . '</span>';
3981 }
3982 $detailUrl = '?dbx_modul=dbxShop_admin&dbx_run1=order_detail&id=' . $id;
3983 $deleteUrl = $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=orders&delete_order=' . $id);
3984
3985 $externalId = trim((string)($record['payment_reference'] ?? ''));
3986 $sourceText = in_array($channel, array('', 'shop', 'web'), true)
3987 ? $report->get_fd_message('source_shop_order')
3988 : $report->get_fd_message('source_channel_order');
3989 $externalText = $externalId !== ''
3990 ? '<small>' . $this->h($report->format_fd_message('external_reference', array('reference' => $externalId))) . '</small>'
3991 : '';
3992 $record['order_view'] = '<div class="dbx-shop-order-main"><strong>' . $this->h($orderNo) . '</strong><small>' . $this->h($created) . '</small><span class="dbx-shop-order-source">' . $this->channelBadge($channel) . '<small>' . $this->h($sourceText) . '</small></span>' . $externalText . '</div>';
3993 $record['customer_view'] = '<div class="dbx-shop-order-customer"><strong>' . $this->h($record['customer_name'] ?? '') . '</strong><small>' . $this->h($record['customer_email'] ?? '') . '</small></div>';
3994 $record['items_view'] = '<div class="dbx-shop-order-items-small">' . $itemLines . '</div>';
3995 $record['status_view'] = '<div class="dbx-shop-order-status-stack">'
3996 . '<span><small>' . $this->h($report->get_fd_message('label_order')) . '</small>' . $this->orderStatusBadge((string)($record['status'] ?? 'new'), $report) . '</span>'
3997 . '<span><small>' . $this->h($report->get_fd_message('label_shipping')) . '</small>' . $this->shippingStatusBadge((string)($record['shipping_status'] ?? 'open'), $report) . '</span>'
3998 . '</div>';
3999 $record['payment_view'] = '<div class="dbx-shop-order-payment">' . $this->paymentStatusBadge((string)($record['payment_status'] ?? 'open'), $report) . '<small>' . $this->h($this->paymentProviderLabel((string)($record['payment_provider'] ?? ''), $report)) . '</small><small>' . $this->h($record['payment_reference'] ?? '') . '</small></div>';
4000 $record['total_view'] = $this->money($record['total_gross'] ?? 0);
4001 $record['actions_view'] = '<span class="dbx-shop-order-actions">'
4002 . $this->openWinButton($detailUrl, $report->format_fd_message('action_edit_title', array('number' => $orderNo)), '<i class="bi bi-pencil-square"></i><span class="visually-hidden"> ' . $this->h($report->get_fd_message('action_edit')) . '</span>', 'btn btn-outline-primary btn-sm', '86%', '88%')
4003 . '<a class="btn btn-outline-danger btn-sm dbxConfirm" href="' . $this->h($deleteUrl) . '" data-confirm-title="<i class=\'bi bi-trash\'></i> ' . $this->h($report->get_fd_message('delete_title')) . '" data-confirm="' . $this->h($report->get_fd_message('delete_question')) . '" data-confirm-hint="<small>' . $this->h($report->get_fd_message('delete_hint')) . '</small>" data-confirm-buttons="yesno" title="' . $this->h($report->get_fd_message('delete_title')) . '"><i class="bi bi-trash"></i><span class="visually-hidden"> ' . $this->h($report->get_fd_message('delete_label')) . '</span></a>'
4004 . '</span>';
4005 return $record;
4006 }
4007
4008 private function orders(): string {
4009 $this->ensureSeed();
4010 $report = dbx()->get_system_obj('dbxReport');
4011 $report->init('shop-orders-report');
4012 $report->_fd = 'dbxShop_admin|rpt-orders-selection';
4013 $report->load_fd_messages();
4014 $report->_action = '?dbx_modul=dbxShop_admin&dbx_run1=orders';
4015 $report->_mode = 'table';
4016 $report->_pages = true;
4017 $report->_create_sel_flds = true;
4018 $report->_but_pagination = 7;
4019 $report->_fld_id = 'id';
4020 $report->_msg_info = $report->get_fd_message('report_info');
4021 $report->add_rep('bar_title', $report->get_fd_message('bar_title'));
4022 $report->add_rep('bar_icon', 'bi-receipt');
4023 $report->add_rep('bar_subtitle', $report->get_fd_message('bar_subtitle'));
4024 $report->add_rep('bar_class', 'dbx-module-bar');
4025 $report->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
4026 $report->add_rep('bar_actions_class', 'dbx-module-bar-actions');
4027 $report->add_rep('bar_title_pre', '');
4028 $report->add_rep('bar_title_heading_attrs', '');
4029 $report->add_rep('bar_middle', '');
4030 $report->add_rep('bar_extra', '');
4031 $report->add_rep('bar_actions', $this->orderActions($report));
4032 $report->add_rep('shop_admin_style', $this->shopAdminStyle());
4033 foreach (array(
4034 'order_view' => 'dbx-shop-col-order',
4035 'customer_view' => 'dbx-shop-col-customer',
4036 'items_view' => 'dbx-shop-col-items',
4037 'status_view' => 'dbx-shop-col-status',
4038 'payment_view' => 'dbx-shop-col-payment',
4039 'total_view' => 'dbx-shop-col-total',
4040 'actions_view' => 'dbx-shop-col-actions',
4041 ) as $field => $class) {
4042 $report->set_class_haeder($field, $class);
4043 $report->_class_body[$field] = $class;
4044 }
4045 $report->set_callback_owner($this);
4046 $report->set_callback('next_record', 'order_report_next_record');
4047 $report->create_selection_fields('dbxShop_admin|rpt-orders-selection');
4048 $this->handleOrderAction($report);
4049
4050 $query = trim((string)$report->get_fld_val('dbx_rwhere', '', 'parameter|max=120'));
4051 $requestedRowsPerPage = (int)$report->get_fld_val('dbx_rrows', 30, 'int');
4052 $rowsPerPage = $requestedRowsPerPage === 0 ? 0 : max(10, min(100, $requestedRowsPerPage));
4053 $position = $rowsPerPage === 0 ? 0 : max(0, (int)$report->get_fld_val('dbx_rpos', 0, 'int'));
4054 $sort = (string)$report->get_fld_val('dbx_rsort', 'create_date', 'parameter');
4055 $direction = strtoupper((string)$report->get_fld_val('dbx_rdesc', 'DESC', 'parameter')) === 'ASC' ? 'ASC' : 'DESC';
4056 $filters = array(
4057 'query' => $query,
4058 'status' => trim((string)$report->get_fld_val('status', '', 'parameter')),
4059 'payment_status' => trim((string)$report->get_fld_val('payment_status', '', 'parameter')),
4060 'shipping_status' => trim((string)$report->get_fld_val('shipping_status', '', 'parameter')),
4061 'channel_key' => trim((string)$report->get_fld_val('channel_key', '', 'parameter')),
4062 );
4063
4064 $filteredCount = $this->repo()->orderCount($filters);
4065 if ($rowsPerPage > 0 && $position >= $filteredCount && $filteredCount > 0) {
4066 $position = max(0, (int)(floor(($filteredCount - 1) / $rowsPerPage) * $rowsPerPage));
4067 }
4068 $orders = $this->repo()->orders($filters, $rowsPerPage, $position, $sort, $direction);
4069
4070 $report->_rflds = array(
4071 'order_view' => $report->get_fd_message('column_order'),
4072 'customer_view' => $report->get_fd_message('column_customer'),
4073 'items_view' => $report->get_fd_message('column_items'),
4074 'status_view' => $report->get_fd_message('column_status'),
4075 'payment_view' => $report->get_fd_message('column_payment'),
4076 'total_view' => $report->get_fd_message('column_total'),
4077 'actions_view' => $report->get_fd_message('column_action'),
4078 );
4079 $report->_rpt_format = array(
4080 'order_view' => 'html',
4081 'customer_view' => 'html',
4082 'items_view' => 'html',
4083 'status_view' => 'html',
4084 'payment_view' => 'html',
4085 'total_view' => 'html',
4086 'actions_view' => 'html',
4087 );
4088 $report->_rrows = $rowsPerPage;
4089 $report->_rpos = $position;
4090 $report->_count_all = $this->repo()->orderCount(array());
4091 $report->_rcount = $filteredCount;
4092 $report->_rdata = $orders;
4093
4094 $content = $report->run();
4095 if ($filteredCount === 0) {
4096 $content .= '<div class="alert alert-info mx-3">'
4097 . $this->h($report->get_fd_message('no_results'))
4098 . '</div>';
4099 }
4100 return $content;
4101 }
4102
4103 public function withdrawal_report_next_record($report, $record) {
4104 if (!is_array($record)) {
4105 return $record;
4106 }
4107 $record['request_view'] = '<div><strong>' . $this->h($record['order_no'] ?? $report->get_fd_message('without_order_no')) . '</strong><br><small>' . $this->h($record['create_date'] ?? '') . '</small></div>';
4108 $record['customer_view'] = '<div><strong>' . $this->h($record['customer_name'] ?? '') . '</strong><br><small>' . $this->h($record['customer_email'] ?? '') . '</small></div>';
4109 $record['message_view'] = '<div class="small">' . nl2br($this->h($record['reason'] ?? '')) . '</div>';
4110 $status = (string)($record['status'] ?? '');
4111 $badge = in_array($status, array('accepted', 'refunded', 'closed'), true) ? 'text-bg-success' : (in_array($status, array('rejected'), true) ? 'text-bg-danger' : 'text-bg-warning');
4112 $statusLabel = $report->get_fd_message('status_' . $status, $status);
4113 $record['status_view'] = '<span class="badge ' . $badge . '">' . $this->h($statusLabel) . '</span>';
4114 $id = (int)($record['id'] ?? 0);
4115 $base = '?dbx_modul=dbxShop_admin&dbx_run1=returns&withdrawal_id=' . $id . '&withdrawal_status=';
4116 $record['actions_view'] =
4117 '<div class="btn-group btn-group-sm" role="group">'
4118 . '<a class="btn btn-outline-secondary" href="' . $this->h($this->actionUrl($base . 'processing')) . '" title="' . $this->h($report->get_fd_message('action_processing')) . '"><i class="bi bi-hourglass-split"></i></a>'
4119 . '<a class="btn btn-outline-success dbxConfirm" href="' . $this->h($this->actionUrl($base . 'accepted')) . '" data-confirm-title="' . $this->h($report->get_fd_message('action_accept_title')) . '" data-confirm="' . $this->h($report->get_fd_message('action_accept_question')) . '" data-confirm-buttons="yesno" title="' . $this->h($report->get_fd_message('action_accept')) . '"><i class="bi bi-check2"></i></a>'
4120 . '<a class="btn btn-outline-primary dbxConfirm" href="' . $this->h($this->actionUrl($base . 'refunded')) . '" data-confirm-title="' . $this->h($report->get_fd_message('action_refund_title')) . '" data-confirm="' . $this->h($report->get_fd_message('action_refund_question')) . '" data-confirm-buttons="yesno" title="' . $this->h($report->get_fd_message('action_refunded')) . '"><i class="bi bi-cash-coin"></i></a>'
4121 . '<a class="btn btn-outline-danger" href="' . $this->h($this->actionUrl($base . 'rejected')) . '" title="' . $this->h($report->get_fd_message('action_reject')) . '"><i class="bi bi-x-lg"></i></a>'
4122 . '<a class="btn btn-outline-secondary" href="' . $this->h($this->actionUrl($base . 'closed')) . '" title="' . $this->h($report->get_fd_message('action_close')) . '"><i class="bi bi-archive"></i></a>'
4123 . '</div>';
4124 return $record;
4125 }
4126
4127 private function returns(): string {
4128 $this->ensureSeed();
4129 $withdrawalId = (int)dbx()->get_modul_var('withdrawal_id', 0, 'int');
4130 $withdrawalStatus = (string)dbx()->get_modul_var('withdrawal_status', '', 'parameter');
4131 if ($withdrawalId > 0 && $withdrawalStatus !== '') {
4132 if ($this->checkActionToken('withdrawal_status')) {
4133 $this->repo()->updateWithdrawalAdmin($withdrawalId, $withdrawalStatus);
4134 }
4135 }
4136 $report = dbx()->get_system_obj('dbxReport');
4137 $report->init('shop-orders-report');
4138 $report->_fd = 'dbxShop_admin|rpt-withdrawals-selection';
4139 $report->load_fd_messages();
4140 $report->_action = '?dbx_modul=dbxShop_admin&dbx_run1=returns';
4141 $report->_mode = 'table';
4142 $report->_pages = true;
4143 $report->_create_sel_flds = true;
4144 $report->_but_pagination = 7;
4145 $report->_fld_id = 'id';
4146 $report->_msg_info = $report->get_fd_message('report_info');
4147 if ($this->postedFormError !== '') {
4148 $report->_msg_error = $report->get_fd_message('token_error');
4149 $this->postedFormError = '';
4150 }
4151 $report->add_rep('bar_title', $report->get_fd_message('bar_title'));
4152 $report->add_rep('bar_icon', 'bi-arrow-counterclockwise');
4153 $report->add_rep('bar_subtitle', $report->get_fd_message('bar_subtitle'));
4154 $report->add_rep('bar_class', 'dbx-module-bar');
4155 $report->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
4156 $report->add_rep('bar_actions_class', 'dbx-module-bar-actions');
4157 $report->add_rep('bar_title_pre', '');
4158 $report->add_rep('bar_title_heading_attrs', '');
4159 $report->add_rep('bar_middle', '');
4160 $report->add_rep('bar_extra', '');
4161 $shopService = dbx()->get_include_obj('dbxShopService', 'dbxShop');
4162 $pages = is_object($shopService) && method_exists($shopService, 'ensureShopLegalPages') ? $shopService->ensureShopLegalPages() : array();
4163 $withdrawalCid = (int)($pages['withdrawal'] ?? 0);
4164 $cmsButton = $withdrawalCid > 0
4165 ? $this->openWinButton('?dbx_modul=dbxContent_admin&dbx_run1=cms&cid=' . $withdrawalCid, $report->get_fd_message('edit_legal_title'), '<i class="bi bi-pencil-square"></i><span class="visually-hidden"> ' . $this->h($report->get_fd_message('edit_cms')) . '</span>', 'btn btn-outline-primary btn-sm me-1', '94%', '92%')
4166 : '';
4167 $report->add_rep('bar_actions',
4168 $cmsButton
4169 . $this->openWinButton('?dbx_modul=dbxShop&dbx_run1=withdrawal', $report->get_fd_message('view_page_title'), '<i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden"> ' . $this->h($report->get_fd_message('shop_view')) . '</span>', 'btn btn-outline-primary btn-sm me-1', '82%', '86%')
4170 );
4171 $report->add_rep('shop_admin_style', $this->shopAdminStyle());
4172 $report->set_callback_owner($this);
4173 $report->set_callback('next_record', 'withdrawal_report_next_record');
4174 $report->create_selection_fields('dbxShop_admin|rpt-withdrawals-selection');
4175
4176 $query = trim((string)$report->get_fld_val('dbx_rwhere', '', 'parameter|max=120'));
4177 $rowsPerPage = (int)$report->get_fld_val('dbx_rrows', 30, 'int');
4178 $rowsPerPage = $rowsPerPage === 0 ? 0 : max(10, min(100, $rowsPerPage));
4179 $position = $rowsPerPage === 0 ? 0 : max(0, (int)$report->get_fld_val('dbx_rpos', 0, 'int'));
4180 $filters = array(
4181 'query' => $query,
4182 'status' => trim((string)$report->get_fld_val('status', '', 'parameter')),
4183 );
4184 $filteredCount = $this->repo()->withdrawalCount($filters);
4185 $rows = $this->repo()->withdrawals($filters, $rowsPerPage, $position);
4186
4187 $report->_rflds = array(
4188 'request_view' => $report->get_fd_message('column_withdrawal'),
4189 'customer_view' => $report->get_fd_message('column_customer'),
4190 'message_view' => $report->get_fd_message('column_message'),
4191 'status_view' => $report->get_fd_message('column_status'),
4192 'actions_view' => $report->get_fd_message('column_action'),
4193 );
4194 $report->_rpt_format = array(
4195 'request_view' => 'html',
4196 'customer_view' => 'html',
4197 'message_view' => 'html',
4198 'status_view' => 'html',
4199 'actions_view' => 'html',
4200 );
4201 $report->_rrows = $rowsPerPage;
4202 $report->_rpos = $position;
4203 $report->_count_all = $this->repo()->withdrawalCount(array());
4204 $report->_rcount = $filteredCount;
4205 $report->_rdata = $rows;
4206
4207 $content = $report->run();
4208 if ($filteredCount === 0) {
4209 $content .= '<div class="alert alert-info mx-3">'
4210 . $this->h($report->get_fd_message('no_results'))
4211 . '</div>';
4212 }
4213 return $content;
4214 }
4215
4216 private function orderMetaHtml(array $order, $texts): string {
4217 $channel = (string)($order['channel_key'] ?? 'shop');
4218 $source = $this->channelLabel($channel) . ' '
4219 . (in_array($channel, array('shop', 'web', ''), true)
4220 ? $texts->get_fd_message('source_order_suffix')
4221 : $texts->get_fd_message('source_channel_order'));
4222 $paymentStatuses = $this->paymentStatusOptions($texts);
4223 $shippingStatuses = $this->shippingStatusOptions($texts);
4224 $rows = array(
4225 $texts->get_fd_message('meta_order_no') => $order['order_no'] ?? '',
4226 $texts->get_fd_message('meta_created') => $order['create_date'] ?? '',
4227 $texts->get_fd_message('meta_customer') => trim((string)($order['customer_name'] ?? '') . ' <' . (string)($order['customer_email'] ?? '') . '>'),
4228 $texts->get_fd_message('meta_phone') => $order['customer_phone'] ?? '',
4229 $texts->get_fd_message('meta_shipping_address') => $order['shipping_address'] ?? '',
4230 $texts->get_fd_message('meta_source') => $source,
4231 $texts->get_fd_message('meta_external_order') => $order['payment_reference'] ?? '',
4232 $texts->get_fd_message('meta_payment_method') => $this->paymentProviderLabel((string)($order['payment_provider'] ?? ''), $texts),
4233 $texts->get_fd_message('meta_payment_status') => $paymentStatuses[(string)($order['payment_status'] ?? '')] ?? ($order['payment_status'] ?? ''),
4234 $texts->get_fd_message('meta_invoice_no') => $order['invoice_no'] ?? '',
4235 $texts->get_fd_message('meta_invoice_date') => $order['invoice_date'] ?? '',
4236 $texts->get_fd_message('meta_invoice_pdf') => trim((string)($order['invoice_pdf_path'] ?? '')) !== '' ? (string)$order['invoice_pdf_path'] : $texts->get_fd_message('not_generated'),
4237 $texts->get_fd_message('meta_stock_reserved') => !empty($order['stock_reserved']) ? $texts->get_fd_message('yes') : $texts->get_fd_message('no'),
4238 $texts->get_fd_message('meta_stock_released') => !empty($order['stock_released']) ? $texts->get_fd_message('yes') . ', ' . (string)($order['stock_released_date'] ?? '') : $texts->get_fd_message('no'),
4239 $texts->get_fd_message('meta_shipping_status') => $shippingStatuses[(string)($order['shipping_status'] ?? '')] ?? ($order['shipping_status'] ?? ''),
4240 $texts->get_fd_message('meta_shipping_provider') => $order['shipping_provider'] ?? '',
4241 $texts->get_fd_message('meta_tracking_no') => $order['tracking_no'] ?? '',
4242 $texts->get_fd_message('meta_total') => $this->money($order['total_gross'] ?? 0),
4243 );
4244 $html = '<dl class="dbx-shop-order-meta">';
4245 foreach ($rows as $label => $value) {
4246 $html .= '<dt>' . $this->h($label) . '</dt><dd>' . $this->h($value) . '</dd>';
4247 }
4248 $html .= '</dl>';
4249 return $html;
4250 }
4251
4252 private function orderHistoryHtml(array $order, $texts): string {
4253 $rows = (array)($order['history'] ?? array());
4254 if ($rows === array()) {
4255 return '<div class="text-muted small">' . $this->h($texts->get_fd_message('history_empty')) . '</div>';
4256 }
4257 $html = '<div class="dbx-shop-order-history">';
4258 foreach ($rows as $row) {
4259 $event = (string)($row['event_type'] ?? '');
4260 $old = (string)($row['old_value'] ?? '');
4261 $new = (string)($row['new_value'] ?? '');
4262 $msg = (string)($row['message'] ?? '');
4263 $html .= '<div class="dbx-shop-order-history-item">'
4264 . '<strong>' . $this->h($event) . '</strong>'
4265 . '<small>' . $this->h($row['create_date'] ?? '') . '</small>'
4266 . ($old !== '' || $new !== '' ? '<code>' . $this->h($old) . ' -> ' . $this->h($new) . '</code>' : '')
4267 . ($msg !== '' ? '<span>' . $this->h($msg) . '</span>' : '')
4268 . '</div>';
4269 }
4270 $html .= '</div>';
4271 return $html;
4272 }
4273
4274 private function orderWithdrawalsHtml(array $order, $texts): string {
4275 $rows = (array)($order['withdrawals'] ?? array());
4276 if ($rows === array()) {
4277 return '<div class="text-muted small">' . $this->h($texts->get_fd_message('withdrawals_empty')) . '</div>';
4278 }
4279 $html = '<div class="dbx-shop-order-withdrawals">';
4280 foreach ($rows as $row) {
4281 $html .= '<div class="alert alert-warning py-2 mb-2">'
4282 . '<strong>' . $this->h($row['customer_name'] ?? '') . '</strong> '
4283 . '<span class="badge text-bg-warning">' . $this->h($row['status'] ?? '') . '</span>'
4284 . '<br><small>' . $this->h($row['create_date'] ?? '') . ' · ' . $this->h($row['customer_email'] ?? '') . '</small>'
4285 . '<div class="mt-1">' . nl2br($this->h($row['reason'] ?? '')) . '</div>'
4286 . '</div>';
4287 }
4288 $html .= '</div>';
4289 return $html;
4290 }
4291
4292 private function orderItemsHtml(array $order, $texts): string {
4293 $items = (array)($order['items'] ?? array());
4294 if ($items === array()) {
4295 return '<div class="text-muted small">' . $this->h($texts->get_fd_message('items_empty')) . '</div>';
4296 }
4297 $html = '<div class="table-responsive"><table class="table table-sm table-bordered dbx-shop-order-items-table"><thead><tr>'
4298 . '<th>' . $this->h($texts->get_fd_message('items_product')) . '</th><th class="text-end">' . $this->h($texts->get_fd_message('items_quantity')) . '</th><th class="text-end">' . $this->h($texts->get_fd_message('items_unit_price')) . '</th><th class="text-end">' . $this->h($texts->get_fd_message('items_tax')) . '</th><th class="text-end">' . $this->h($texts->get_fd_message('items_shipping')) . '</th><th class="text-end">' . $this->h($texts->get_fd_message('items_total')) . '</th>'
4299 . '</tr></thead><tbody>';
4300 foreach ($items as $item) {
4301 $html .= '<tr>'
4302 . '<td><strong>' . $this->h($item['title'] ?? '') . '</strong><br><code>' . $this->h($item['sku'] ?? '') . '</code></td>'
4303 . '<td class="text-end">' . (int)($item['qty'] ?? 0) . '</td>'
4304 . '<td class="text-end">' . $this->money($item['price_gross'] ?? 0) . '</td>'
4305 . '<td class="text-end">' . $this->h(number_format((float)($item['tax_rate'] ?? 0), 2, ',', '.')) . ' %</td>'
4306 . '<td class="text-end">' . $this->money($item['shipping_gross'] ?? 0) . '</td>'
4307 . '<td class="text-end">' . $this->money($item['total_gross'] ?? 0) . '</td>'
4308 . '</tr>';
4309 }
4310 $html .= '</tbody></table></div>';
4311 return $html;
4312 }
4313
4314 private function orderPayloadHtml(array $order, $texts): string {
4315 $blocks = array(
4316 $texts->get_fd_message('payload_payment') => trim((string)($order['payment_payload'] ?? '')),
4317 $texts->get_fd_message('payload_legal') => trim((string)($order['legal_snapshot'] ?? '')),
4318 $texts->get_fd_message('payload_withdrawal') => trim((string)($order['withdrawal_snapshot'] ?? '')),
4319 );
4320 $html = '';
4321 foreach ($blocks as $title => $payload) {
4322 if ($payload === '') {
4323 continue;
4324 }
4325 $decoded = json_decode($payload, true);
4326 if (is_array($decoded)) {
4327 $payload = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
4328 }
4329 $html .= '<details class="mb-2"><summary>' . $this->h($title) . '</summary><pre>' . $this->h($payload) . '</pre></details>';
4330 }
4331 if ($html === '') {
4332 return '<div class="text-muted small">' . $this->h($texts->get_fd_message('payload_empty')) . '</div>';
4333 }
4334 return $html;
4335 }
4336
4337 private function statusMailChangesHtml(array $before, array $order): string {
4338 $rows = array(
4339 'Bestellstatus' => array($this->orderStatusOptions(), 'status'),
4340 'Zahlungsstatus' => array($this->paymentStatusOptions(), 'payment_status'),
4341 'Versandstatus' => array($this->shippingStatusOptions(), 'shipping_status'),
4342 );
4343 $html = '';
4344 foreach ($rows as $label => $cfg) {
4345 [$options, $field] = $cfg;
4346 $old = (string)($before[$field] ?? '');
4347 $new = (string)($order[$field] ?? '');
4348 if ($old === $new) {
4349 continue;
4350 }
4351 $html .= '<tr><th align="left">' . $this->h($label) . '</th><td>' . $this->h($options[$old] ?? $old) . '</td><td>' . $this->h($options[$new] ?? $new) . '</td></tr>';
4352 }
4353 if ($html === '') {
4354 return '<dl>'
4355 . '<dt>Bestellstatus</dt><dd>' . $this->h($this->orderStatusOptions()[(string)($order['status'] ?? '')] ?? (string)($order['status'] ?? '')) . '</dd>'
4356 . '<dt>Zahlungsstatus</dt><dd>' . $this->h($this->paymentStatusOptions()[(string)($order['payment_status'] ?? '')] ?? (string)($order['payment_status'] ?? '')) . '</dd>'
4357 . '<dt>Versandstatus</dt><dd>' . $this->h($this->shippingStatusOptions()[(string)($order['shipping_status'] ?? '')] ?? (string)($order['shipping_status'] ?? '')) . '</dd>'
4358 . '</dl>';
4359 }
4360 return '<table border="0" cellpadding="6" cellspacing="0">'
4361 . '<thead><tr><th align="left">Feld</th><th align="left">Vorher</th><th align="left">Jetzt</th></tr></thead>'
4362 . '<tbody>' . $html . '</tbody></table>';
4363 }
4364
4365 private function sendOrderStatusMail(array $before, array $order): array {
4366 $cfg = $this->shopConfig();
4367 $from = trim((string)($cfg['mail_from'] ?? ''));
4368 $fromName = trim((string)($cfg['mail_from_name'] ?? 'dbxShop'));
4369 $profile = trim((string)($cfg['mail_profile'] ?? ''));
4370 $to = trim((string)($order['customer_email'] ?? ''));
4371 if (filter_var($from, FILTER_VALIDATE_EMAIL) === false) {
4372 return array(false, 'Kundenmail wurde nicht gesendet: Der Mail-Absender in den Shop-Einstellungen ist ungültig.');
4373 }
4374 if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
4375 return array(false, 'Kundenmail wurde nicht gesendet: Die Bestellung hat keine gueltige Kunden-E-Mail.');
4376 }
4377
4378 $orderNo = (string)($order['order_no'] ?? '');
4379 $subject = 'Aktualisierung Ihrer Bestellung ' . $orderNo;
4380 $trackingNo = trim((string)($order['tracking_no'] ?? ''));
4381 $trackingUrl = trim((string)($order['tracking_url'] ?? ''));
4382 $invoiceNo = trim((string)($order['invoice_no'] ?? ''));
4383 $extra = '';
4384 if ($trackingNo !== '') {
4385 $extra .= '<p><strong>Trackingnummer:</strong> ' . $this->h($trackingNo) . '</p>';
4386 }
4387 if ($trackingUrl !== '') {
4388 $extra .= '<p><a href="' . $this->h($trackingUrl) . '">Sendung verfolgen</a></p>';
4389 }
4390 if ($invoiceNo !== '') {
4391 $extra .= '<p><strong>Rechnung:</strong> ' . $this->h($invoiceNo) . '</p>';
4392 }
4393 $html = '<h2>Ihre Bestellung wurde aktualisiert</h2>'
4394 . '<p>Bestellnummer: <strong>' . $this->h($orderNo) . '</strong></p>'
4395 . $this->statusMailChangesHtml($before, $order)
4396 . $extra
4397 . '<p>Viele Gruesse<br>Ihr Shop-Team</p>';
4398
4399 try {
4400 $options = $profile !== '' ? array('mail_profile' => $profile) : array();
4401 $sent = dbx()->send_mail(
4402 array('email' => $from, 'name' => $fromName),
4403 $to,
4404 $subject,
4405 $html,
4406 'html',
4407 array(),
4408 $options
4409 );
4410 if (!$sent) {
4411 $mail = dbx()->get_system_obj('dbxMail');
4412 $reason = is_object($mail) ? trim((string)$mail->get_error()) : '';
4413 return array(
4414 false,
4415 'Kundenmail konnte nicht gesendet werden'
4416 . ($reason !== '' ? ': ' . $reason : '.')
4417 );
4418 }
4419 $this->repo()->addOrderHistory((int)($order['id'] ?? 0), 'customer_mail', '', $to, 'Statusbenachrichtigung wurde an den Kunden gesendet.');
4420 return array(true, 'Kundenmail wurde gesendet.');
4421 } catch (\Throwable $e) {
4422 dbx()->sys_msg('error', 'dbxShop_admin', (string)($order['id'] ?? ''), 'order status mail failed', $e->getMessage());
4423 return array(false, 'Kundenmail konnte nicht gesendet werden: ' . $e->getMessage());
4424 }
4425 }
4426
4427 private function notifyCustomerHint(array $order, $texts): string {
4428 $email = trim((string)($order['customer_email'] ?? ''));
4429 return $email === ''
4430 ? $texts->get_fd_message('notify_no_email')
4431 : $texts->format_fd_message(
4432 'notify_hint',
4433 array('email' => $email)
4434 );
4435 }
4436
4437 private function orderQuickActionsHtml(array $order, $texts): string {
4438 $id = (int)($order['id'] ?? 0);
4439 if ($id <= 0) {
4440 return '';
4441 }
4442 $base = '?dbx_modul=dbxShop_admin&dbx_run1=order_detail&dbx_run2=quick_action&id=' . $id . '&order_action=';
4443 $actions = array(
4444 'mark_paid' => array('bi-cash-coin', $texts->get_fd_message('quick_paid'), $texts->get_fd_message('quick_paid_confirm')),
4445 'processing' => array('bi-tools', $texts->get_fd_message('quick_processing'), $texts->get_fd_message('quick_processing_confirm')),
4446 'ready' => array('bi-box-seam', $texts->get_fd_message('quick_ready'), $texts->get_fd_message('quick_ready_confirm')),
4447 'shipped' => array('bi-truck', $texts->get_fd_message('quick_shipped'), $texts->get_fd_message('quick_shipped_confirm')),
4448 'delivered' => array('bi-check2-circle', $texts->get_fd_message('quick_delivered'), $texts->get_fd_message('quick_delivered_confirm')),
4449 'cancel' => array('bi-x-circle', $texts->get_fd_message('quick_cancel'), $texts->get_fd_message('quick_cancel_confirm')),
4450 'refund' => array('bi-arrow-counterclockwise', $texts->get_fd_message('quick_refund'), $texts->get_fd_message('quick_refund_confirm')),
4451 );
4452 $html = '<div class="dbx-shop-order-quick-actions" data-dbx="lib=confirm|class=dbxConfirm|bind=link">'
4453 . '<strong>' . $this->h($texts->get_fd_message('quick_actions')) . '</strong><div class="dbx-shop-order-quick-action-buttons">';
4454 foreach ($actions as $action => $cfg) {
4455 [$icon, $label, $confirm] = $cfg;
4456 $btnClass = in_array($action, array('cancel', 'refund'), true) ? 'btn-outline-danger' : 'btn-outline-primary';
4457 $html .= '<a class="btn btn-sm ' . $btnClass . ' dbxConfirm" href="' . $this->h($this->actionUrl($base . rawurlencode($action))) . '"'
4458 . ' data-confirm-title="<i class=\'bi ' . $this->h($icon) . '\'></i> ' . $this->h($label) . '"'
4459 . ' data-confirm="' . $this->h($confirm) . '"'
4460 . ' data-confirm-buttons="yesno"'
4461 . ' title="' . $this->h($label) . '">'
4462 . '<i class="bi ' . $this->h($icon) . '"></i> ' . $this->h($label) . '</a>';
4463 }
4464 $html .= '</div></div>';
4465 return $html;
4466 }
4467
4468 private function orderDetailActions(int $id, $texts): string {
4469 $helpId = $this->ensureShopOrdersHelpPage();
4470 $helpButton = $helpId > 0
4471 ? $this->openWinButton('?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId, $texts->get_fd_message('help_orders'), '<i class="bi bi-question-circle"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('help_label')) . '</span>', 'btn btn-outline-secondary btn-sm ms-1', '72%', '82%')
4472 : '';
4473 $deleteUrl = $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=orders&delete_order=' . $id);
4474 $mailUrl = $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=order_detail&dbx_run2=send_status_mail&id=' . $id);
4475 $invoicePdfUrl = $this->actionUrl('?dbx_modul=dbxShop_admin&dbx_run1=order_invoice_pdf&id=' . $id);
4476 return '<button class="btn btn-primary btn-sm" type="submit" title="' . $this->h($texts->get_fd_message('save_order_title')) . '"><i class="bi bi-save"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('save_label')) . '</span></button>'
4477 . $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=order_invoice&id=' . $id, $texts->get_fd_message('invoice_view'), '<i class="bi bi-file-earmark-text"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('invoice_label')) . '</span>', 'btn btn-outline-primary btn-sm ms-1', '82%', '86%')
4478 . '<a class="btn btn-outline-danger btn-sm ms-1" href="' . $this->h($invoicePdfUrl) . '" target="_blank" rel="noopener" title="' . $this->h($texts->get_fd_message('invoice_pdf_title')) . '"><i class="bi bi-file-earmark-pdf"></i><span class="visually-hidden"> PDF</span></a>'
4479 . '<a class="btn btn-outline-primary btn-sm ms-1 dbxConfirm" href="' . $this->h($mailUrl) . '" data-confirm-title="<i class=\'bi bi-envelope\'></i> ' . $this->h($texts->get_fd_message('customer_mail_title')) . '" data-confirm="' . $this->h($texts->get_fd_message('customer_mail_question')) . '" data-confirm-buttons="yesno" title="' . $this->h($texts->get_fd_message('customer_mail_title')) . '"><i class="bi bi-envelope"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('customer_mail_label')) . '</span></a>'
4480 . '<a class="btn btn-outline-danger btn-sm ms-1 dbxConfirm" href="' . $this->h($deleteUrl) . '" data-confirm-title="<i class=\'bi bi-trash\'></i> ' . $this->h($texts->get_fd_message('delete_title')) . '" data-confirm="' . $this->h($texts->get_fd_message('delete_question')) . '" data-confirm-buttons="yesno" title="' . $this->h($texts->get_fd_message('delete_title')) . '"><i class="bi bi-trash"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('delete_label')) . '</span></a>'
4481 . $helpButton
4482 . $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=orders', $texts->get_fd_message('order_list_title'), '<i class="bi bi-table"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('order_list_label')) . '</span>', 'btn btn-outline-secondary btn-sm ms-1', '92%', '88%');
4483 }
4484
4485 private function orderDetail(): string {
4486 $id = (int)dbx()->get_modul_var('id', 0, 'int');
4487 $order = $id > 0 ? $this->repo()->orderById($id) : null;
4488 if (!is_array($order)) {
4489 return $this->frame('<div class="alert alert-warning m-3">Bestellung nicht gefunden.</div>', 'Bestellung bearbeiten', $this->orderActions());
4490 }
4491 $quickMessage = '';
4492 $quickError = '';
4493 $quickAction = (string)dbx()->get_modul_var('order_action', '', 'parameter');
4494 if ((string)dbx()->get_modul_var('dbx_run2', '', 'parameter') === 'quick_action' && $quickAction !== '') {
4495 if (!$this->checkActionToken('order_quick_action')) {
4496 $quickOk = false;
4497 $quickMsg = $this->postedFormError;
4498 } else {
4499 [$quickOk, $quickMsg] = $this->repo()->updateOrderQuickAction($id, $quickAction);
4500 }
4501 if ($quickOk) {
4502 $quickMessage = $quickMsg;
4503 $order = $this->repo()->orderById($id) ?: $order;
4504 } else {
4505 $quickError = $quickMsg;
4506 }
4507 }
4508 $mailMessage = '';
4509 $mailError = '';
4510 $sendStatusMail = (string)dbx()->get_modul_var('dbx_run2', '', 'parameter') === 'send_status_mail'
4511 || (string)($_GET['dbx_run2'] ?? '') === 'send_status_mail';
4512 if ($sendStatusMail) {
4513 if (!$this->checkActionToken('send_status_mail')) {
4514 $mailOk = false;
4515 $mailMsg = $this->postedFormError;
4516 } else {
4517 [$mailOk, $mailMsg] = $this->sendOrderStatusMail($order, $order);
4518 }
4519 if ($mailOk) {
4520 $mailMessage = $mailMsg;
4521 $order = $this->repo()->orderById($id) ?: $order;
4522 } else {
4523 $mailError = $mailMsg;
4524 }
4525 }
4526
4527 $form = dbx()->get_system_obj('dbxForm');
4528 $form->init('shop-order-form', 'shop-order-form');
4529 $form->_dd = 'dbxShop|shopOrder';
4530 $form->_fd = 'dbxShop_admin|rpt-orders-selection';
4531 $form->load_fd_messages();
4532 $form->_data = $order;
4533 $form->_rid = $id;
4534 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=order_detail&id=' . $id;
4535 $form->set_activ_id($id);
4536 $form->add_rep('bar_title', $form->get_fd_message('detail_title'));
4537 $form->add_rep('bar_icon', 'bi-receipt');
4538 $form->add_rep('bar_subtitle', (string)($order['order_no'] ?? ''));
4539 $form->add_rep('bar_class', 'dbx-module-bar');
4540 $form->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
4541 $form->add_rep('bar_title_pre', '');
4542 $form->add_rep('bar_title_heading_attrs', '');
4543 $form->add_rep('bar_middle', '');
4544 $form->add_rep('bar_actions_class', 'dbx-module-bar-actions');
4545 $form->add_rep('bar_actions', $this->orderDetailActions($id, $form));
4546 $form->add_rep('bar_extra', '');
4547 $form->add_rep('shop_admin_style', $this->shopAdminStyle());
4548 $form->_msg_info = $form->get_fd_message('detail_info');
4549 $form->add_fld('status', tpl: 'select-single-label', label: $form->get_fd_message('field_order_status'), options: $this->orderStatusOptions($form));
4550 $form->add_fld('payment_status', tpl: 'select-single-label', label: $form->get_fd_message('field_payment_status'), options: $this->paymentStatusOptions($form));
4551 $form->add_fld('payment_reference', tpl: 'text-label', label: $form->get_fd_message('field_payment_reference'), rules: '*|max=180', placeholder: 'PAYID-xxxx / Channel-Order-ID');
4552 $form->add_fld('invoice_no', tpl: 'text-label', label: $form->get_fd_message('field_invoice_no'), rules: '*|max=60', placeholder: 'R2026-00001');
4553 $form->add_fld('invoice_date', tpl: 'text-label', label: $form->get_fd_message('field_invoice_date'), rules: '*|date', placeholder: date('Y-m-d'));
4554 $form->add_fld('shipping_status', tpl: 'select-single-label', label: $form->get_fd_message('field_shipping_status'), options: $this->shippingStatusOptions($form));
4555 $form->add_fld('shipping_provider', tpl: 'text-label', label: $form->get_fd_message('field_shipping_provider'), rules: '*|max=120', placeholder: 'DHL, UPS');
4556 $form->add_fld('tracking_no', tpl: 'text-label', label: $form->get_fd_message('field_tracking_no'), rules: '*|max=180', placeholder: '00340434123456789012');
4557 $form->add_fld('tracking_url', tpl: 'text-label', label: $form->get_fd_message('field_tracking_url'), rules: '*|max=255', placeholder: 'https://...');
4558 $form->add_fld('shipped_date', tpl: 'text-label', label: $form->get_fd_message('field_shipped_date'), rules: '*|datetime', placeholder: date('Y-m-d H:i:s'));
4559 $form->add_fld('note', tpl: 'textarea-label', label: $form->get_fd_message('field_note'), rules: '*|max=5000', data: 'rows=5', placeholder: $form->get_fd_message('note_placeholder'));
4560 $form->add_rep('order_notice', '');
4561
4562 if ($form->submit()) {
4563 if (!$form->errors()) {
4564 if ($this->repo()->updateOrderAdmin($id, $_POST)) {
4565 $form->_msg_success = $form->get_fd_message(
4566 'order_save_success'
4567 );
4568 $order = $this->repo()->orderById($id) ?: $order;
4569 $form->_data = $order;
4570 } else {
4571 $form->_msg_error = $form->get_fd_message(
4572 'order_save_error'
4573 );
4574 }
4575 } else {
4576 $form->_msg_error = $form->get_fd_message(
4577 'validation_error'
4578 );
4579 }
4580 }
4581
4582 $actionMessage = $quickMessage !== '' ? $quickMessage : $mailMessage;
4583 $actionError = $quickError !== '' ? $quickError : $mailError;
4584 if ($actionMessage !== '' || $actionError !== '') {
4585 $form->_form_submit = 1;
4586 $form->_msg_info = '';
4587 if ($actionError !== '') {
4588 $form->_msg_error = $actionError;
4589 $form->add_fld_error('general', $actionError);
4590 } else {
4591 $form->_msg_success = $actionMessage;
4592 }
4593 }
4594
4595 $form->add_rep('notify_customer_hint', $this->h($this->notifyCustomerHint($order, $form)));
4596 $form->add_rep('order_quick_actions', $this->orderQuickActionsHtml($order, $form));
4597 $form->add_obj('order_meta', 'obj-value', $this->orderMetaHtml($order, $form));
4598 $form->add_obj('order_items', 'obj-value', $this->orderItemsHtml($order, $form));
4599 $form->add_obj('order_payload', 'obj-value', $this->orderPayloadHtml($order, $form));
4600 $form->add_obj('order_history', 'obj-value', $this->orderHistoryHtml($order, $form));
4601 $form->add_obj('order_withdrawals', 'obj-value', $this->orderWithdrawalsHtml($order, $form));
4602 return $form->run();
4603 }
4604
4605 private function orderInvoice(): string {
4606 $id = (int)dbx()->get_modul_var('id', 0, 'int');
4607 $order = $id > 0 ? $this->repo()->orderById($id) : null;
4608 if (!is_array($order)) {
4609 return $this->frame('<div class="alert alert-warning m-3">Bestellung nicht gefunden.</div>', 'Rechnung');
4610 }
4611 $invoiceNo = trim((string)($order['invoice_no'] ?? ''));
4612 if ($invoiceNo === '') {
4613 $invoiceNo = 'Entwurf';
4614 }
4615 $rows = '';
4616 foreach ((array)($order['items'] ?? array()) as $item) {
4617 $rows .= $this->tpl()->get_tpl('dbxShop_admin|order-invoice-row', array(
4618 'title' => $this->h($item['title'] ?? ''),
4619 'sku' => $this->h($item['sku'] ?? ''),
4620 'qty' => (string)(int)($item['qty'] ?? 0),
4621 'price_gross' => $this->money($item['price_gross'] ?? 0),
4622 'tax_rate' => $this->h(number_format((float)($item['tax_rate'] ?? 0), 2, ',', '.')),
4623 'total_gross' => $this->money($item['total_gross'] ?? 0),
4624 ));
4625 }
4626 $html = $this->tpl()->get_tpl('dbxShop_admin|order-invoice', array(
4627 'invoice_no' => $this->h($invoiceNo),
4628 'invoice_date' => $this->h($order['invoice_date'] ?? date('Y-m-d')),
4629 'order_no' => $this->h($order['order_no'] ?? ''),
4630 'customer_name' => $this->h($order['customer_name'] ?? ''),
4631 'customer_email' => $this->h($order['customer_email'] ?? ''),
4632 'shipping_address' => nl2br($this->h($order['shipping_address'] ?? '')),
4633 'rows' => $rows,
4634 'total_gross' => $this->money($order['total_gross'] ?? 0),
4635 ));
4636 return $this->frame($html, 'Rechnung ' . $invoiceNo, '');
4637 }
4638
4639 private function orderInvoicePdf(): string {
4640 $id = (int)dbx()->get_modul_var('id', 0, 'int');
4641 if (!$this->checkActionToken('order_invoice_pdf')) {
4642 return $this->frame('<div class="alert alert-danger m-3">' . $this->h($this->postedFormError) . '</div>', 'Rechnung');
4643 }
4644 $order = $id > 0 ? $this->repo()->ensureOrderInvoicePdf($id) : null;
4645 if (!is_array($order)) {
4646 return $this->frame('<div class="alert alert-warning m-3">Rechnungs-PDF konnte nicht erzeugt werden.</div>', 'Rechnung');
4647 }
4648 $file = $this->repo()->invoicePdfAbsolutePath($order);
4649 if ($file === '') {
4650 return $this->frame('<div class="alert alert-warning m-3">Rechnungs-PDF ist nicht verfuegbar.</div>', 'Rechnung');
4651 }
4652 if (!headers_sent()) {
4653 header('Content-Type: application/pdf');
4654 header('Content-Disposition: inline; filename="' . basename($file) . '"');
4655 header('Content-Length: ' . filesize($file));
4656 }
4657 readfile($file);
4658 exit;
4659 }
4660
4661 private function shopAdminCardForm(string $fid, string $dd, array $data, int $id, string $action, string $shopAction, string $saveAction, string $titleHtml, string $subtitle = '', string $cardClass = '') {
4662 $form = dbx()->get_system_obj('dbxForm');
4663 $form->init($fid, 'shop-admin-card-form');
4664 $form->_dd = $dd;
4665 $form->_fd = array(
4666 'dbxShop|shopProductGroup' => 'dbxShop|shop-product-group',
4667 'dbxShop|shopAttributeDefinition' => 'dbxShop|shop-attribute-definition',
4668 'dbxShop|shopProductAttributeValue' => 'dbxShop|shop-product-attribute-value',
4669 'dbxShop|shopShippingGroup' => 'dbxShop|shop-shipping-group',
4670 'dbxShop|shopChannelGroup' => 'dbxShop|shop-channel-group',
4671 )[$dd] ?? '';
4672 if ($form->_fd !== '') {
4673 $form->load_fd_messages();
4674 }
4675 $form->set_form_help_enabled(false);
4676 $form->_data = $data + array('id' => $id);
4677 $form->_rid = $id;
4678 $form->_action = $action;
4679 $form->set_activ_id($id);
4680 $form->add_rep('form_class', 'dbx-shop-admin-card-dbXForm');
4681 $form->add_rep('form_attrs', 'data-target="dbxForm_{i}" data-dbx="lib=confirm|class=dbxConfirm|bind=button"');
4682 $form->add_rep('shop_action', $this->h($shopAction));
4683 $form->add_rep('save_action', $this->h($saveAction));
4684 $form->add_rep('record_id', (string)$id);
4685 $form->add_rep('extra_hidden', '');
4686 $form->add_rep('card_title', $titleHtml);
4687 $form->add_rep('card_subtitle', $this->h($subtitle));
4688 $form->add_rep('card_badges', '');
4689 $form->add_rep('card_class', $this->h($cardClass));
4690 $form->add_rep('form_body', '');
4691 $form->add_rep('delete_button', '');
4692 $form->_msg_info = '';
4693 return $form;
4694 }
4695
4696 private function shopAdminCardDeleteButton(string $action, string $title, string $message): string {
4697 return '<button class="btn btn-outline-danger btn-sm dbxConfirm" name="shop_action" value="' . $this->h($action) . '" title="' . $this->h($title) . '" data-confirm-title="' . $this->h($title) . '" data-confirm="' . $this->h($message) . '" data-confirm-buttons="yesno"><i class="bi bi-trash"></i></button>';
4698 }
4699
4700 private function activeBadge(array $row, $texts = null): string {
4701 $texts = $texts ?: $this->catalogTexts();
4702 return ((int)($row['active'] ?? 0) === 1)
4703 ? '<span class="badge text-bg-success">' . $this->h($texts->get_fd_message('active')) . '</span>'
4704 : '<span class="badge text-bg-secondary">' . $this->h($texts->get_fd_message('inactive')) . '</span>';
4705 }
4706
4707 private function groups(): string {
4708 $this->ensureSeed();
4709 $texts = $this->catalogTexts();
4710 if ($this->posted('delete_product_group')) {
4711 $this->repo()->deleteProductGroup((int)($_POST['id'] ?? 0));
4712 } elseif ($this->posted('save_product_group')) {
4713 $this->repo()->updateProductGroup((int)($_POST['id'] ?? 0), $_POST);
4714 }
4715
4716 $cardHtml = function (array $group, bool $isNew = false) use ($texts): string {
4717 $id = (int)($group['id'] ?? 0);
4718 if ($isNew) {
4719 $title = '<span>' . $this->h($texts->get_fd_message('groups_new')) . '</span>';
4720 $subtitle = $texts->get_fd_message('groups_new_subtitle');
4721 } else {
4722 $title = '<code>' . $this->h($group['group_key'] ?? '') . '</code><span>' . $this->h($group['title'] ?? '') . '</span>';
4723 $subtitle = trim((string)($group['description'] ?? ''));
4724 }
4725
4726 $form = $this->shopAdminCardForm(
4727 'shop-product-group-' . ($isNew ? 'new' : $id),
4728 'dbxShop|shopProductGroup',
4729 $group,
4730 $id,
4731 '?dbx_modul=dbxShop_admin&dbx_run1=groups' . ($isNew ? '&new=1' : ''),
4732 'save_product_group',
4733 'save_product_group',
4734 $title,
4735 $subtitle,
4736 'dbx-shop-product-group-card'
4737 );
4738 $form->add_rep('card_badges', $isNew ? '<span class="badge text-bg-primary">' . $this->h($texts->get_fd_message('new')) . '</span>' : $this->activeBadge($group, $texts));
4739 $form->add_rep('extra_hidden', '<input type="hidden" name="display_variant" value="' . $this->h($group['display_variant'] ?? 'gallery_grid') . '">');
4740 if (!$isNew) {
4741 $form->add_rep('delete_button', $this->shopAdminCardDeleteButton('delete_product_group', $texts->get_fd_message('groups_delete_title'), $texts->get_fd_message('groups_delete_confirm')));
4742 }
4743 if ($isNew) {
4744 $form->add_fld('group_key', tpl: 'text-label', placeholder: 'artikelgruppe-key', rules: '*|parameter|max=80');
4745 }
4746 $form->add_fld('parent_id', tpl: 'select-single-label', options: $this->productGroupOptions($id, true, $texts), rules: 'int');
4747 $form->add_fld('title', tpl: 'text-label', placeholder: $texts->get_fd_message('groups_title_placeholder'), rules: '*|max=160');
4748 $form->add_fld('description', tpl: 'textarea-label', placeholder: $texts->get_fd_message('description_placeholder'), data: 'rows=2');
4749 $form->add_fld('tax_class', tpl: 'select-single-label', options: 'mwst1=mwst1&mwst2=mwst2&mwst3=mwst3');
4750 $form->add_fld('card_template', tpl: 'select-single-label', options: array(
4751 'product-card-default' => $texts->get_fd_message('card_default'),
4752 'product-card-compact' => $texts->get_fd_message('card_compact'),
4753 ));
4754 $form->add_fld('detail_template', tpl: 'select-single-label', options: array(
4755 'product-detail-default' => $texts->get_fd_message('detail_default'),
4756 'product-detail-technical' => $texts->get_fd_message('detail_technical'),
4757 ));
4758 $form->add_fld('gallery_template', tpl: 'select-single-label', options: array(
4759 'image-gallery' => $texts->get_fd_message('gallery_images'),
4760 'file-gallery' => $texts->get_fd_message('gallery_files'),
4761 ));
4762 $form->add_fld('gallery_visible_count', tpl: 'text-label', rules: 'int');
4763 $form->add_fld('gallery_image_size', tpl: 'select-single-label', options: 'original=Original&cover=Cover&contain=Contain');
4764 $form->add_fld('gallery_lightbox_width', tpl: 'text-label', placeholder: '100vw');
4765 $form->add_fld('gallery_overflow', tpl: 'select-single-label', options: array(
4766 'grid' => 'Grid',
4767 'slider' => 'Slider',
4768 'scroll' => 'Scroll',
4769 'laufband' => $texts->get_fd_message('gallery_marquee'),
4770 'tutorial' => 'Tutorial',
4771 ));
4772 $form->add_fld('gallery_click', tpl: 'select-single-label', options: array(
4773 'lightbox' => 'Lightbox',
4774 'none' => $texts->get_fd_message('gallery_no_click'),
4775 'newtab' => $texts->get_fd_message('gallery_new_tab'),
4776 'viewerjs' => 'ViewerJS',
4777 'photoswipe' => 'PhotoSwipe',
4778 ));
4779 $form->add_fld('attribute_notes', tpl: 'textarea-label', placeholder: $texts->get_fd_message('attribute_notes_placeholder'), data: 'rows=2');
4780 $channelDefaults = '';
4781 if ($this->channelsEnabled()) {
4782 $form->add_fld('ebay_category_id', tpl: 'text-label', placeholder: '58058');
4783 $form->add_fld('amazon_product_type', tpl: 'text-label', placeholder: 'SOFTWARE / PRODUCT / SHIRT');
4784 $form->add_fld('kleinanzeigen_category_id', tpl: 'text-label', placeholder: 'category_12345');
4785 $form->add_fld('mobile_category_id', tpl: 'text-label', placeholder: 'car');
4786 $channelDefaults = '<div class="wide dbx-shop-channel-defaults">'
4787 . '<h6>' . $this->h($texts->get_fd_message('channel_defaults_title')) . '</h6>'
4788 . '<p>' . $this->h($texts->get_fd_message('channel_defaults_info')) . '</p>'
4789 . '<div class="dbx-shop-admin-card-grid dbx-shop-channel-default-grid">'
4790 . '<div>{obj:ebay_category_id}</div>'
4791 . '<div>{obj:amazon_product_type}</div>'
4792 . '<div>{obj:kleinanzeigen_category_id}</div>'
4793 . '<div>{obj:mobile_category_id}</div>'
4794 . '</div>'
4795 . '</div>';
4796 }
4797 $form->add_fld('sorter', tpl: 'text-label', rules: 'int');
4798 $form->add_fld('active', tpl: 'checkbox-label', rules: 'int');
4799 $groupImagePanel = $this->productGroupImagePanel($group, $isNew, $texts);
4800 $form->add_rep('form_body',
4801 '<div class="dbx-shop-admin-card-grid">'
4802 . ($isNew ? '<div>{obj:group_key}</div>' : '')
4803 . '<div>{obj:parent_id}</div>'
4804 . '<div>{obj:title}</div>'
4805 . '<div>{obj:tax_class}</div>'
4806 . '<div>{obj:sorter}</div>'
4807 . '<div>{obj:active}</div>'
4808 . '<div class="wide">' . $groupImagePanel . '</div>'
4809 . '<div class="wide">{obj:description}</div>'
4810 . '<div>{obj:card_template}</div>'
4811 . '<div>{obj:detail_template}</div>'
4812 . '<div>{obj:gallery_template}</div>'
4813 . '<div>{obj:gallery_visible_count}</div>'
4814 . '<div>{obj:gallery_image_size}</div>'
4815 . '<div>{obj:gallery_lightbox_width}</div>'
4816 . '<div>{obj:gallery_overflow}</div>'
4817 . '<div>{obj:gallery_click}</div>'
4818 . '<div class="wide">{obj:attribute_notes}</div>'
4819 . $channelDefaults
4820 . '</div>'
4821 );
4822 return $form->run();
4823 };
4824
4825 $groups = $this->repo()->groups();
4826 $cards = '';
4827 if ((int)($_GET['new'] ?? 0) === 1) {
4828 $sorter = 10;
4829 foreach ($groups as $group) {
4830 $sorter = max($sorter, (int)($group['sorter'] ?? 0) + 10);
4831 }
4832 $cards .= $cardHtml(array(
4833 'tax_class' => (string)($this->shopConfig()['default_tax_class'] ?? 'mwst1'),
4834 'default_tax_rate' => 19,
4835 'parent_id' => 0,
4836 'display_variant' => 'gallery_grid',
4837 'card_template' => 'product-card-default',
4838 'detail_template' => 'product-detail-default',
4839 'gallery_template' => 'image-gallery',
4840 'gallery_visible_count' => 3,
4841 'gallery_image_size' => 'original',
4842 'gallery_lightbox_width' => '100vw',
4843 'gallery_overflow' => 'grid',
4844 'gallery_click' => 'lightbox',
4845 'active' => 1,
4846 'sorter' => $sorter,
4847 ), true);
4848 }
4849 foreach ($groups as $group) {
4850 $cards .= $cardHtml($group);
4851 }
4852 $helpButton = $this->helpButton($this->ensureShopProductGroupsHelpPage(), $texts->get_fd_message('groups_help'));
4853 $barActions = '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=groups&new=1" title="' . $this->h($texts->get_fd_message('groups_new_title')) . '"><i class="bi bi-plus-square"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('groups_new')) . '</span></a>' . $helpButton;
4854 $content = '<div class="alert alert-info mx-3 mt-3 mb-0">' . $this->h($texts->get_fd_message('groups_intro')) . '</div>'
4855 . '<div class="dbx-shop-admin-card-list">' . $cards . '</div>';
4856 if ($groups !== array()) {
4857 // Eine gemeinsame Medienbrowser-Vorlage genuegt fuer alle Karten.
4858 // Sie steht bewusst nach allen Kartenformularen.
4859 $content .= $this->shopMediaFormTemplates($this->shopMediaConfig());
4860 }
4861 return $this->frame($content, $texts->get_fd_message('groups_title'), $barActions);
4862 }
4863
4864 private function attributes(): string {
4865 $this->ensureSeed();
4866 $texts = $this->catalogTexts();
4867 if ($this->posted('save_attribute_definition')) {
4868 $this->repo()->saveAttributeDefinition($_POST);
4869 }
4870
4871 $groupOptions = array();
4872 foreach ($this->repo()->groups() as $group) {
4873 $groupOptions[(string)(int)($group['id'] ?? 0)] = (string)($group['title'] ?? '');
4874 }
4875
4876 $cardHelpButton = $this->helpButton($this->ensureShopProductAttributesHelpPage(), $texts->get_fd_message('attributes_help'), 'btn btn-outline-secondary btn-sm me-1');
4877 $cardHtml = function (array $attribute, bool $isNew = false) use ($groupOptions, $cardHelpButton, $texts): string {
4878 $id = (int)($attribute['id'] ?? 0);
4879 $type = (string)($attribute['input_type'] ?? 'text');
4880 $title = $isNew
4881 ? '<span>' . $this->h($texts->get_fd_message('attributes_new')) . '</span>'
4882 : '<code>' . $this->h($attribute['attr_key'] ?? '') . '</code><span>' . $this->h($attribute['title'] ?? '') . '</span>';
4883 $subtitle = $isNew ? $texts->get_fd_message('attributes_new_subtitle') : (string)($attribute['group_title'] ?? '');
4884
4885 $form = $this->shopAdminCardForm(
4886 'shop-attribute-definition-' . ($isNew ? 'new' : $id),
4887 'dbxShop|shopAttributeDefinition',
4888 $attribute,
4889 $id,
4890 '?dbx_modul=dbxShop_admin&dbx_run1=attributes',
4891 'save_attribute_definition',
4892 'save_attribute_definition',
4893 $title,
4894 $subtitle,
4895 'dbx-shop-attribute-card'
4896 );
4897 $form->add_rep('card_badges', $cardHelpButton . ($isNew ? '<span class="badge text-bg-primary">' . $this->h($texts->get_fd_message('new')) . '</span>' : $this->activeBadge($attribute, $texts)));
4898 $form->add_fld('group_id', tpl: 'select-single-label', options: $groupOptions, rules: 'int');
4899 $form->add_fld('attr_key', tpl: 'text-label', placeholder: $texts->get_fd_message('attributes_key_placeholder'), rules: '*|parameter|max=80');
4900 $form->add_fld('title', tpl: 'text-label', placeholder: $texts->get_fd_message('attributes_title_placeholder'), rules: '*|max=160');
4901 $form->add_fld('input_type', tpl: 'select-single-label', options: array(
4902 'text' => $texts->get_fd_message('attributes_type_text'),
4903 'select' => $texts->get_fd_message('attributes_type_select'),
4904 'number' => $texts->get_fd_message('attributes_type_number'),
4905 ));
4906 $form->add_fld('unit', tpl: 'text-label', placeholder: 'cm');
4907 $form->add_fld('options', tpl: 'textarea-label', placeholder: 'S|M|L|XL', data: 'rows=2');
4908 $form->add_fld('required', tpl: 'checkbox-label', rules: 'int');
4909 $form->add_fld('filterable', tpl: 'checkbox-label', rules: 'int');
4910 $form->add_fld('comparable', tpl: 'checkbox-label', rules: 'int');
4911 $form->add_fld('sorter', tpl: 'text-label', rules: 'int');
4912 $form->add_fld('active', tpl: 'checkbox-label', rules: 'int');
4913 $form->add_rep('form_body',
4914 '<div class="dbx-shop-admin-card-grid">'
4915 . '<div>{obj:group_id}</div>'
4916 . '<div>{obj:attr_key}</div>'
4917 . '<div>{obj:title}</div>'
4918 . '<div>{obj:input_type}</div>'
4919 . '<div>{obj:unit}</div>'
4920 . '<div>{obj:sorter}</div>'
4921 . '<div class="wide">{obj:options}</div>'
4922 . '<div class="wide dbx-shop-admin-check-grid">{obj:required}{obj:filterable}{obj:comparable}{obj:active}</div>'
4923 . '</div>'
4924 );
4925 return $form->run();
4926 };
4927
4928 $cards = $cardHtml(array(
4929 'group_id' => (int)array_key_first($groupOptions),
4930 'input_type' => 'text',
4931 'required' => 0,
4932 'filterable' => 1,
4933 'comparable' => 0,
4934 'active' => 1,
4935 'sorter' => 100,
4936 ), true);
4937 foreach ($this->repo()->allAttributeDefinitions() as $attribute) {
4938 $cards .= $cardHtml($attribute);
4939 }
4940
4941 $barActions = $this->helpButton($this->ensureShopProductAttributesHelpPage(), $texts->get_fd_message('attributes_help'));
4942 return $this->frame('<div class="alert alert-info mx-3 mt-3 mb-0">' . $this->h($texts->get_fd_message('attributes_intro')) . '</div><div class="dbx-shop-admin-card-list">' . $cards . '</div>', $texts->get_fd_message('attributes_title'), $barActions);
4943 }
4944
4945 private function productAttributes(): string {
4946 $this->ensureSeed();
4947 $texts = $this->catalogTexts();
4948 $productId = (int)dbx()->get_modul_var('id', '0', 'int');
4949 if ($this->posted('save_product_attributes')) {
4950 $productId = (int)($_POST['product_id'] ?? $productId);
4951 $this->repo()->saveProductAttributeValues($productId, $_POST['attr_value'] ?? array());
4952 }
4953 $product = $this->repo()->productById($productId);
4954 if (!$product) {
4955 return $this->placeholder($texts->get_fd_message('attributes_title'), $texts->get_fd_message('attributes_product_not_found'));
4956 }
4957
4958 $valueMap = array();
4959 foreach (($product['attributes'] ?? array()) as $attribute) {
4960 $valueMap[(int)($attribute['id'] ?? 0)] = (string)($attribute['value_text'] ?? '');
4961 }
4962
4963 $rows = '';
4964 foreach ($this->repo()->attributeDefinitionsForProduct($productId, true) as $definition) {
4965 $id = (int)($definition['id'] ?? 0);
4966 $value = $valueMap[$id] ?? '';
4967 $type = (string)($definition['input_type'] ?? 'text');
4968 $input = '';
4969 if ($type === 'select') {
4970 $input = '<select class="form-select form-select-sm" name="attr_value[' . $id . ']"><option value="">-</option>';
4971 foreach ($this->attributeOptions((string)($definition['options'] ?? '')) as $option) {
4972 $input .= '<option value="' . $this->h($option) . '"' . ($option === $value ? ' selected' : '') . '>' . $this->h($option) . '</option>';
4973 }
4974 if ($value !== '' && strpos((string)($definition['options'] ?? ''), $value) === false) {
4975 $input .= '<option value="' . $this->h($value) . '" selected>' . $this->h($value) . '</option>';
4976 }
4977 $input .= '</select>';
4978 } else {
4979 $inputType = $type === 'number' ? 'number' : 'text';
4980 $step = $type === 'number' ? ' step="0.01"' : '';
4981 $input = '<input class="form-control form-control-sm" type="' . $inputType . '"' . $step . ' name="attr_value[' . $id . ']" value="' . $this->h($value) . '">';
4982 }
4983 $rows .= '<tr>';
4984 $rows .= '<td><strong>' . $this->h($definition['title'] ?? '') . '</strong><br><small><code>' . $this->h($definition['attr_key'] ?? '') . '</code></small></td>';
4985 $rows .= '<td>' . $input . '</td>';
4986 $rows .= '<td>' . $this->h($definition['unit'] ?? '') . '</td>';
4987 $rows .= '<td>' . ((int)($definition['required'] ?? 0) === 1 ? '<span class="badge text-bg-warning">' . $this->h($texts->get_fd_message('required')) . '</span>' : '') . ' ' . ((int)($definition['filterable'] ?? 0) === 1 ? '<span class="badge text-bg-info">' . $this->h($texts->get_fd_message('filter')) . '</span>' : '') . '</td>';
4988 $rows .= '</tr>';
4989 }
4990
4991 if ($rows === '') {
4992 $rows = '<tr><td colspan="4" class="text-muted">' . $this->h($texts->get_fd_message('attributes_none')) . '</td></tr>';
4993 }
4994
4995 $form = $this->shopAdminCardForm(
4996 'shop-product-attributes-' . $productId,
4997 'dbxShop|shopProductAttributeValue',
4998 array('id' => $productId),
4999 $productId,
5000 '?dbx_modul=dbxShop_admin&dbx_run1=product_attributes&id=' . $productId,
5001 'save_product_attributes',
5002 'save_product_attributes',
5003 '<code>' . $this->h($product['sku'] ?? '') . '</code><span>' . $this->h($product['title'] ?? '') . '</span>',
5004 $texts->get_fd_message('attributes_value_subtitle'),
5005 'dbx-shop-product-attributes-card'
5006 );
5007 $form->add_rep('extra_hidden', '<input type="hidden" name="product_id" value="' . $productId . '">');
5008 $form->add_rep('card_badges', $this->helpButton($this->ensureShopProductAttributesHelpPage(), $texts->get_fd_message('attributes_help'), 'btn btn-outline-secondary btn-sm me-1') . '<a class="btn btn-outline-secondary btn-sm" href="?dbx_modul=dbxShop_admin&dbx_run1=products">' . $this->h($texts->get_fd_message('back')) . '</a>');
5009 $form->add_rep('form_body', '<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>' . $this->h($texts->get_fd_message('column_attribute')) . '</th><th>' . $this->h($texts->get_fd_message('column_value')) . '</th><th>' . $this->h($texts->get_fd_message('column_unit')) . '</th><th>' . $this->h($texts->get_fd_message('column_property')) . '</th></tr></thead><tbody>' . $rows . '</tbody></table></div>');
5010 return $this->frame('<div class="dbx-shop-admin-card-list">' . $form->run() . '</div>', $texts->get_fd_message('attributes_edit_title'));
5011 }
5012
5013 private function shippingGroups(): string {
5014 $this->ensureSeed();
5015 $texts = $this->catalogTexts();
5016 if ($this->posted('delete_shipping_group')) {
5017 $this->repo()->deleteShippingGroup((int)($_POST['id'] ?? 0));
5018 } elseif ($this->posted('save_shipping_group')) {
5019 $this->repo()->updateShippingGroup((int)($_POST['id'] ?? 0), $_POST);
5020 }
5021
5022 $cardHelpButton = $this->helpButton($this->ensureShopShippingGroupsHelpPage(), $texts->get_fd_message('shipping_help'), 'btn btn-outline-secondary btn-sm me-1');
5023 $cardHtml = function (array $group, bool $isNew = false) use ($cardHelpButton, $texts): string {
5024 $id = (int)($group['id'] ?? 0);
5025 if ($isNew) {
5026 $title = '<span>' . $this->h($texts->get_fd_message('shipping_new')) . '</span>';
5027 $subtitle = $texts->get_fd_message('shipping_new_subtitle');
5028 } else {
5029 $title = '<code>' . $this->h($group['group_key'] ?? '') . '</code><span>' . $this->h($group['title'] ?? '') . '</span>';
5030 $subtitle = trim((string)($group['description'] ?? ''));
5031 }
5032
5033 $form = $this->shopAdminCardForm(
5034 'shop-shipping-group-' . ($isNew ? 'new' : $id),
5035 'dbxShop|shopShippingGroup',
5036 $group,
5037 $id,
5038 '?dbx_modul=dbxShop_admin&dbx_run1=shipping_groups' . ($isNew ? '&new=1' : ''),
5039 'save_shipping_group',
5040 'save_shipping_group',
5041 $title,
5042 $subtitle,
5043 'dbx-shop-shipping-group-card'
5044 );
5045 $form->add_rep('card_badges', $cardHelpButton . ($isNew ? '<span class="badge text-bg-primary">' . $this->h($texts->get_fd_message('new')) . '</span>' : $this->activeBadge($group, $texts)));
5046 if (!$isNew) {
5047 $form->add_rep('delete_button', $this->shopAdminCardDeleteButton('delete_shipping_group', $texts->get_fd_message('shipping_delete_title'), $texts->get_fd_message('shipping_delete_confirm')));
5048 }
5049 if ($isNew) {
5050 $form->add_fld('group_key', tpl: 'text-label', placeholder: 'versandgruppe-key', rules: '*|parameter|max=80');
5051 }
5052 $form->add_fld('title', tpl: 'text-label', placeholder: $texts->get_fd_message('shipping_new'), rules: '*|max=160');
5053 $form->add_fld('description', tpl: 'textarea-label', placeholder: $texts->get_fd_message('description_placeholder'), data: 'rows=2');
5054 $form->add_fld('shipping_way', tpl: 'text-label', placeholder: $texts->get_fd_message('shipping_way_placeholder'));
5055 $form->add_fld('delivery_time', tpl: 'text-label', placeholder: $texts->get_fd_message('shipping_time_placeholder'));
5056 $form->add_fld('shipping_gross', tpl: 'text-label', placeholder: '5.90', rules: 'decimal');
5057 $form->add_fld('free_from_gross', tpl: 'text-label', placeholder: '-1', rules: 'decimal');
5058 $form->add_fld('sorter', tpl: 'text-label', rules: 'int');
5059 $form->add_fld('active', tpl: 'checkbox-label', rules: 'int');
5060 $form->add_rep('form_body',
5061 '<div class="dbx-shop-admin-card-grid">'
5062 . ($isNew ? '<div>{obj:group_key}</div>' : '')
5063 . '<div>{obj:title}</div>'
5064 . '<div>{obj:shipping_way}</div>'
5065 . '<div>{obj:delivery_time}</div>'
5066 . '<div>{obj:shipping_gross}</div>'
5067 . '<div>{obj:free_from_gross}</div>'
5068 . '<div>{obj:sorter}</div>'
5069 . '<div>{obj:active}</div>'
5070 . '<div class="wide">{obj:description}</div>'
5071 . '</div>'
5072 );
5073 return $form->run();
5074 };
5075
5076 $groups = $this->repo()->shippingGroups();
5077 $cards = '';
5078 if ((int)($_GET['new'] ?? 0) === 1) {
5079 $sorter = 10;
5080 foreach ($groups as $group) {
5081 $sorter = max($sorter, (int)($group['sorter'] ?? 0) + 10);
5082 }
5083 $cards .= $cardHtml(array(
5084 'shipping_gross' => 0,
5085 'free_from_gross' => -1,
5086 'active' => 1,
5087 'sorter' => $sorter,
5088 ), true);
5089 }
5090 foreach ($groups as $group) {
5091 $cards .= $cardHtml($group);
5092 }
5093 $helpButton = $this->helpButton($this->ensureShopShippingGroupsHelpPage(), $texts->get_fd_message('shipping_help'));
5094 $barActions = '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=shipping_groups&new=1" title="' . $this->h($texts->get_fd_message('shipping_new_title')) . '"><i class="bi bi-plus-square"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('shipping_new')) . '</span></a>' . $helpButton;
5095 return $this->frame('<div class="alert alert-info mx-3 mt-3 mb-0">' . $this->h($texts->get_fd_message('shipping_intro')) . '</div><div class="dbx-shop-admin-card-list">' . $cards . '</div>', $texts->get_fd_message('shipping_title'), $barActions);
5096 }
5097
5098 private function channelGroups(): string {
5099 $this->ensureSeed();
5100 $texts = $this->catalogTexts();
5101 if ($this->posted('delete_channel_group')) {
5102 $this->repo()->deleteChannelGroup((int)($_POST['id'] ?? 0));
5103 } elseif ($this->posted('save_channel_group')) {
5104 $this->repo()->updateChannelGroup((int)($_POST['id'] ?? 0), $_POST, array_map('strval', $_POST['channels'] ?? array()));
5105 }
5106 $channels = $this->repo()->channels();
5107 $groups = $this->repo()->channelGroups();
5108 $cardHelpButton = $this->helpButton($this->ensureShopChannelHelpPage(), $texts->get_fd_message('channel_groups_help'), 'btn btn-outline-secondary btn-sm me-1');
5109 $cardHtml = function (array $group, bool $isNew = false) use ($channels, $cardHelpButton, $texts): string {
5110 $id = (int)($group['id'] ?? 0);
5111 $active = array();
5112 foreach (($group['channels'] ?? array()) as $channel) {
5113 if ((int)($channel['active'] ?? 0) === 1) {
5114 $active[] = (string)$channel['channel_key'];
5115 }
5116 }
5117 $checks = '<div class="dbx-shop-admin-check-grid">';
5118 foreach ($channels as $channel) {
5119 $key = (string)($channel['channel_key'] ?? '');
5120 if ($key === '') {
5121 continue;
5122 }
5123 $checks .= '<label><input type="checkbox" name="channels[]" value="' . $this->h($key) . '"' . (in_array($key, $active, true) ? ' checked' : '') . '> <span>' . $this->h($channel['title'] ?? $key) . '</span></label>';
5124 }
5125 $checks .= '</div>';
5126
5127 if ($isNew) {
5128 $title = '<span>' . $this->h($texts->get_fd_message('channel_groups_new')) . '</span>';
5129 $subtitle = $texts->get_fd_message('channel_groups_new_subtitle');
5130 } else {
5131 $title = '<code>' . $this->h($group['group_key'] ?? '') . '</code><span>' . $this->h($group['title'] ?? '') . '</span>';
5132 $subtitle = trim((string)($group['description'] ?? ''));
5133 }
5134
5135 $form = $this->shopAdminCardForm(
5136 'shop-channel-group-' . ($isNew ? 'new' : $id),
5137 'dbxShop|shopChannelGroup',
5138 $group,
5139 $id,
5140 '?dbx_modul=dbxShop_admin&dbx_run1=channel_groups' . ($isNew ? '&new=1' : ''),
5141 'save_channel_group',
5142 'save_channel_group',
5143 $title,
5144 $subtitle,
5145 'dbx-shop-channel-group-card'
5146 );
5147 $form->add_rep('card_badges', $cardHelpButton . ($isNew ? '<span class="badge text-bg-primary">' . $this->h($texts->get_fd_message('new')) . '</span>' : $this->activeBadge($group, $texts)));
5148 if (!$isNew) {
5149 $form->add_rep('delete_button', $this->shopAdminCardDeleteButton('delete_channel_group', $texts->get_fd_message('channel_groups_delete_title'), $texts->get_fd_message('channel_groups_delete_confirm')));
5150 }
5151 if ($isNew) {
5152 $form->add_fld('group_key', tpl: 'text-label', placeholder: 'neue-channel-gruppe', rules: '*|parameter|max=80');
5153 }
5154 $form->add_fld('title', tpl: 'text-label', placeholder: $texts->get_fd_message('channel_groups_new'), rules: '*|max=160');
5155 $form->add_fld('description', tpl: 'textarea-label', placeholder: $texts->get_fd_message('description_placeholder'), data: 'rows=2');
5156 $form->add_fld('sorter', tpl: 'text-label', rules: 'int');
5157 $form->add_fld('active', tpl: 'checkbox-label', rules: 'int');
5158 $form->add_obj('channel_checks', 'obj-value', $checks);
5159 $form->add_rep('form_body',
5160 '<div class="dbx-shop-admin-card-grid">'
5161 . ($isNew ? '<div>{obj:group_key}</div>' : '')
5162 . '<div>{obj:title}</div>'
5163 . '<div>{obj:sorter}</div>'
5164 . '<div>{obj:active}</div>'
5165 . '<div class="wide">{obj:description}</div>'
5166 . '<div class="wide"><label class="form-label">' . $this->h($texts->get_fd_message('channels_label')) . '</label>{obj:channel_checks}</div>'
5167 . '</div>'
5168 );
5169 return $form->run();
5170 };
5171
5172 $cards = '';
5173 if ((int)($_GET['new'] ?? 0) === 1) {
5174 $sorter = 10;
5175 foreach ($groups as $group) {
5176 $sorter = max($sorter, (int)($group['sorter'] ?? 0) + 10);
5177 }
5178 $cards .= $cardHtml(array(
5179 'title' => '',
5180 'description' => '',
5181 'active' => 1,
5182 'sorter' => $sorter,
5183 'channels' => array(),
5184 ), true);
5185 }
5186 foreach ($groups as $group) {
5187 $cards .= $cardHtml($group);
5188 }
5189 $helpButton = $this->helpButton($this->ensureShopChannelHelpPage(), $texts->get_fd_message('channel_groups_help'));
5190 $barActions = '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=channel_groups&new=1" title="' . $this->h($texts->get_fd_message('channel_groups_new_title')) . '"><i class="bi bi-plus-square"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('channel_groups_new')) . '</span></a>' . $helpButton;
5191 return $this->frame('<div class="alert alert-info mx-3 mt-3 mb-0">' . $this->h($texts->get_fd_message('channel_groups_intro')) . '</div><div class="dbx-shop-admin-card-list">' . $cards . '</div>', $texts->get_fd_message('channel_groups_title'), $barActions);
5192 }
5193
5194 private function shopMediaDir(bool $ensure = false): string {
5195 $dir = dirname(__DIR__, 4) . '/files/shop/img';
5196 if ($ensure && !is_dir($dir)) {
5197 mkdir($dir, 0775, true);
5198 }
5199 return $dir;
5200 }
5201
5202 private function safeFileName(string $name): string {
5203 $name = strtolower(trim($name));
5204 $name = preg_replace('~[^a-z0-9._-]+~', '-', $name);
5205 $name = trim((string)$name, '-.');
5206 return $name !== '' ? $name : 'shop-image';
5207 }
5208
5209 private function handleMediaUpload(): string {
5210 if (!$this->posted('upload_media')) {
5211 return '';
5212 }
5213 if (empty($_FILES['shop_image']['tmp_name']) || !is_uploaded_file($_FILES['shop_image']['tmp_name'])) {
5214 return '<div class="alert alert-warning m-3">Keine Datei ausgewaehlt.</div>';
5215 }
5216 $original = (string)($_FILES['shop_image']['name'] ?? 'shop-image');
5217 $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
5218 $allowed = array('jpg','jpeg','png','gif','webp','svg');
5219 if (!in_array($ext, $allowed, true)) {
5220 return '<div class="alert alert-danger m-3">Dieser Bildtyp ist fuer Shop-Medien nicht erlaubt.</div>';
5221 }
5222
5223 $base = $this->safeFileName(pathinfo($original, PATHINFO_FILENAME));
5224 $name = $base . '.' . $ext;
5225 $target = $this->shopMediaDir(true) . '/' . $name;
5226 $i = 2;
5227 while (is_file($target)) {
5228 $name = $base . '-' . $i . '.' . $ext;
5229 $target = $this->shopMediaDir() . '/' . $name;
5230 $i++;
5231 }
5232 if (!move_uploaded_file($_FILES['shop_image']['tmp_name'], $target)) {
5233 return '<div class="alert alert-danger m-3">Upload konnte nicht gespeichert werden.</div>';
5234 }
5235
5236 $rel = 'files/shop/img/' . $name;
5237 $productId = (int)($_POST['product_id'] ?? 0);
5238 $groupId = (int)($_POST['group_id'] ?? 0);
5239 if ($productId > 0 || $groupId > 0) {
5240 $this->repo()->saveImage($productId, $groupId, $rel, (string)($_POST['title'] ?? $base), (string)($_POST['alt'] ?? $base), !empty($_POST['is_primary']) ? 1 : 0, (int)($_POST['sorter'] ?? 100));
5241 }
5242 return '<div class="alert alert-success m-3">Bild wurde hochgeladen: ' . $this->h($name) . '</div>';
5243 }
5244
5245 private function media(): string {
5246 $this->ensureSeed();
5247 $texts = $this->catalogTexts();
5248 $selectedProduct = (int)dbx()->get_modul_var('product_id', '0', 'int');
5249 $selectedGroup = (int)dbx()->get_modul_var('group_id', '0', 'int');
5250
5251 $productOptions = '<option value="0">' . $this->h($texts->get_fd_message('media_no_product')) . '</option>';
5252 foreach ($this->repo()->products(false) as $product) {
5253 $sel = (int)($product['id'] ?? 0) === $selectedProduct ? ' selected' : '';
5254 $productOptions .= '<option value="' . (int)($product['id'] ?? 0) . '"' . $sel . '>' . $this->h($product['title'] ?? '') . '</option>';
5255 }
5256 $groupOptions = '<option value="0">' . $this->h($texts->get_fd_message('media_no_group')) . '</option>';
5257 foreach ($this->repo()->groups() as $group) {
5258 $sel = (int)($group['id'] ?? 0) === $selectedGroup ? ' selected' : '';
5259 $groupOptions .= '<option value="' . (int)($group['id'] ?? 0) . '"' . $sel . '>' . $this->h($group['title'] ?? '') . '</option>';
5260 }
5261
5262 $mediaCfg = $this->shopMediaConfig();
5263 $attrs = $this->shopMediaAttrs($mediaCfg);
5264
5265 $html = '<div class="dbx-shop-media-manager m-3"' . $attrs . '>';
5266 $targetForm = dbx()->get_system_obj('dbxForm');
5267 $targetForm->init('shop-media-target-form', 'shop-media-target-form');
5268 $targetForm->_action = '?dbx_modul=dbxShop_admin&dbx_run1=media';
5269 $targetForm->set_form_help_enabled(false);
5270 $targetForm->add_rep('frame_skip_form_wrap', '1');
5271 $targetForm->add_rep('form_body',
5272 '<div class="row g-2 align-items-end">'
5273 . '<div class="col-md-4"><label class="form-label">' . $this->h($texts->get_fd_message('media_product')) . '</label><select class="form-select form-select-sm" name="product_id" data-shop-product-select>' . $productOptions . '</select></div>'
5274 . '<div class="col-md-4"><label class="form-label">' . $this->h($texts->get_fd_message('media_group')) . '</label><select class="form-select form-select-sm" name="group_id" data-shop-group-select>' . $groupOptions . '</select></div>'
5275 . '<div class="col-md-2"><label class="form-label">' . $this->h($texts->get_fd_message('media_sort')) . '</label><input class="form-control form-control-sm" name="sorter" value="100" data-shop-sorter></div>'
5276 . '<div class="col-md-2 form-check pb-1"><input class="form-check-input" type="checkbox" name="is_primary" value="1" id="shop_img_primary" data-shop-primary><label class="form-check-label" for="shop_img_primary">' . $this->h($texts->get_fd_message('media_primary')) . '</label></div>'
5277 . '<div class="col-md-8"><div class="form-text">' . $this->h($texts->get_fd_message('media_hint')) . '</div></div>'
5278 . '<div class="col-md-2"><button class="btn btn-outline-secondary btn-sm w-100"><i class="bi bi-filter"></i> ' . $this->h($texts->get_fd_message('media_load_selection')) . '</button></div>'
5279 . '<div class="col-md-2"><button type="button" class="btn btn-outline-primary btn-sm w-100 dbx-shop-media-pick" data-shop-media-folder="img/shop" title="' . $this->h($texts->get_fd_message('media_select_title')) . '"><i class="bi bi-images"></i><i class="bi bi-camera-video"></i><i class="bi bi-upload"></i><span>' . $this->h($texts->get_fd_message('selection')) . '</span></button></div>'
5280 . '</div>'
5281 );
5282 $html .= $targetForm->run();
5283
5284 $assigned = '';
5285 foreach ($this->repo()->allImages() as $image) {
5286 $assigned .= '<div class="col"><div class="card h-100"><img src="' . $this->h($this->mediaItemUrl($image, true)) . '" class="card-img-top" alt="" style="height:120px;object-fit:cover;"><div class="card-body p-2"><strong>' . $this->h($image['title'] ?? '') . '</strong><br><small>' . $this->h($image['product_title'] ?: $image['group_title'] ?: $texts->get_fd_message('not_assigned')) . '</small></div></div></div>';
5287 }
5288 $html .= '<div class="m-3"><h5>' . $this->h($texts->get_fd_message('media_assigned_title')) . '</h5><div class="row row-cols-2 row-cols-md-4 row-cols-xl-6 g-2">' . $assigned . '</div></div>';
5289
5290 $files = glob($this->shopMediaDir() . '/*.{jpg,jpeg,png,gif,webp,svg}', GLOB_BRACE) ?: array();
5291 $fileCards = '';
5292 foreach ($files as $file) {
5293 $rel = 'files/shop/img/' . basename($file);
5294 $fileCards .= '<div class="col"><div class="card h-100"><img src="' . $this->h($this->mediaUrl($rel)) . '" class="card-img-top" alt="" style="height:120px;object-fit:cover;"><div class="card-body p-2"><small>' . $this->h(basename($file)) . '</small></div></div></div>';
5295 }
5296 $html .= '<div class="m-3"><h5>' . $this->h($texts->get_fd_message('media_legacy_title')) . '</h5><div class="row row-cols-2 row-cols-md-4 row-cols-xl-6 g-2">' . $fileCards . '</div></div>';
5297 $html .= $this->shopMediaFormTemplates($mediaCfg);
5298 $html .= '</div>';
5299
5300 $barActions = $this->helpButton($this->ensureShopMediaHelpPage(), $texts->get_fd_message('media_help'));
5301 return $this->frame($html, $texts->get_fd_message('media_title'), $barActions);
5302 }
5303
5304 private function channels(): string {
5305 $this->ensureSeed();
5306 $texts = $this->catalogTexts();
5307 $notice = '';
5308 if ($this->posted('delete_channel')) {
5309 $this->repo()->deleteChannel((int)($_POST['id'] ?? 0));
5310 } elseif ($this->posted('save_channel')) {
5311 $this->repo()->updateChannel((int)($_POST['id'] ?? 0), $_POST);
5312 } elseif ($this->posted('test_channel')) {
5313 $id = (int)($_POST['id'] ?? 0);
5314 if ($id > 0) {
5315 $this->repo()->updateChannel($id, $_POST);
5316 $result = $this->repo()->testChannelConnection($id);
5317 $notice = '<div class="alert ' . (!empty($result['ok']) ? 'alert-success' : 'alert-warning') . ' m-3">' . $this->h($result['message'] ?? '') . '</div>';
5318 } else {
5319 $notice = '<div class="alert alert-warning m-3">' . $this->h($texts->get_fd_message('channels_save_first')) . '</div>';
5320 }
5321 }
5322
5323 $platformOptions = array(
5324 'shop' => 'Shop',
5325 'amazon' => 'Amazon',
5326 'ebay' => 'eBay',
5327 'kleinanzeigen' => 'Kleinanzeigen',
5328 'mobile' => 'mobile.de',
5329 'custom' => $texts->get_fd_message('channels_platform_custom'),
5330 );
5331 $modeOptions = array(
5332 'internal' => $texts->get_fd_message('channels_mode_internal'),
5333 'manual' => $texts->get_fd_message('channels_mode_manual'),
5334 'api' => 'API',
5335 'feed' => 'Feed',
5336 'webhook' => 'Webhook',
5337 );
5338
5339 $platformHints = array(
5340 'shop' => array($texts->get_fd_message('channels_hint_shop_api'), $texts->get_fd_message('channels_hint_shop_listing'), $texts->get_fd_message('channels_hint_shop_feedback')),
5341 'amazon' => array($texts->get_fd_message('channels_hint_amazon_api'), $texts->get_fd_message('channels_hint_amazon_listing'), $texts->get_fd_message('channels_hint_amazon_feedback')),
5342 'ebay' => array($texts->get_fd_message('channels_hint_ebay_api'), $texts->get_fd_message('channels_hint_ebay_listing'), $texts->get_fd_message('channels_hint_ebay_feedback')),
5343 'kleinanzeigen' => array($texts->get_fd_message('channels_hint_classified_api'), $texts->get_fd_message('channels_hint_classified_listing'), $texts->get_fd_message('channels_hint_classified_feedback')),
5344 'mobile' => array($texts->get_fd_message('channels_hint_mobile_api'), $texts->get_fd_message('channels_hint_mobile_listing'), $texts->get_fd_message('channels_hint_mobile_feedback')),
5345 'custom' => array($texts->get_fd_message('channels_hint_custom_api'), $texts->get_fd_message('channels_hint_custom_listing'), $texts->get_fd_message('channels_hint_custom_feedback')),
5346 );
5347
5348 $rowHtml = function (array $channel, bool $isNew = false) use ($platformOptions, $modeOptions, $platformHints, $texts): string {
5349 $id = (int)($channel['id'] ?? 0);
5350 $key = (string)($channel['channel_key'] ?? '');
5351 $platform = (string)($channel['platform_type'] ?? 'custom');
5352 $hint = $platformHints[$platform] ?? $platformHints['custom'];
5353 $placeholderMap = array(
5354 'shop' => array(
5355 'channel_key' => 'shop',
5356 'api_base_url' => 'nicht benoetigt',
5357 'api_client_id' => 'nicht benoetigt',
5358 'api_username' => 'nicht benoetigt',
5359 'api_client_secret' => 'nicht benoetigt',
5360 'api_access_token' => 'nicht benoetigt',
5361 'api_refresh_token' => 'nicht benoetigt',
5362 'api_password' => 'nicht benoetigt',
5363 'marketplace_id' => 'nicht benoetigt',
5364 'seller_id' => 'nicht benoetigt',
5365 'account_id' => 'nicht benoetigt',
5366 'location_key' => 'nicht benoetigt',
5367 'category_id' => 'nicht benoetigt',
5368 'payment_policy_id' => 'nicht benoetigt',
5369 'fulfillment_policy_id' => 'nicht benoetigt',
5370 'return_policy_id' => 'nicht benoetigt',
5371 'notification_destination' => 'nicht benoetigt',
5372 'notification_topic' => 'nicht benoetigt',
5373 'webhook_secret' => 'nicht benoetigt',
5374 'webhook_url' => 'nicht benoetigt',
5375 'api_scope' => 'nicht benoetigt',
5376 ),
5377 'amazon' => array(
5378 'channel_key' => 'amazon',
5379 'api_base_url' => 'https://sellingpartnerapi-eu.amazon.com',
5380 'api_client_id' => 'amzn1.application-oa2-client.keyxxxx',
5381 'api_username' => 'nicht benoetigt bei OAuth',
5382 'api_client_secret' => 'amzn1.oa2-cs.v1.secretxxxx',
5383 'api_access_token' => 'Atza|access_token_xxxx',
5384 'api_refresh_token' => 'Atzr|refresh_token_xxxx',
5385 'api_password' => 'nicht benoetigt bei SP-API',
5386 'marketplace_id' => 'A1PA6795UKMFR9',
5387 'seller_id' => 'A1SELLERIDXXXX',
5388 'account_id' => 'account_xxxx',
5389 'location_key' => 'nicht benoetigt',
5390 'category_id' => 'productType: SOFTWARE / PRODUCT',
5391 'payment_policy_id' => 'nicht benoetigt',
5392 'fulfillment_policy_id' => 'nicht benoetigt',
5393 'return_policy_id' => 'nicht benoetigt',
5394 'notification_destination' => 'arn:aws:sqs:eu-central-1:123456789012:amazon-orders',
5395 'notification_topic' => 'ORDER_CHANGE',
5396 'webhook_secret' => 'secret_64zeichen_xxxx',
5397 'webhook_url' => 'wird von dbxShop erzeugt',
5398 'api_scope' => "Listings Items\nOrders\nNotifications",
5399 ),
5400 'ebay' => array(
5401 'channel_key' => 'ebay',
5402 'api_base_url' => 'https://api.ebay.com',
5403 'api_client_id' => 'keyxxxx-appid',
5404 'api_username' => 'nicht benoetigt bei OAuth',
5405 'api_client_secret' => 'certid-secretxxxx',
5406 'api_access_token' => 'v^1.1#i^1#p^3#access_token_xxxx',
5407 'api_refresh_token' => 'v^1.1#r^1#p^3#refresh_token_xxxx',
5408 'api_password' => 'nicht benoetigt bei OAuth',
5409 'marketplace_id' => 'EBAY_DE',
5410 'seller_id' => 'sellername_xxxx',
5411 'account_id' => 'account_xxxx',
5412 'location_key' => 'default',
5413 'category_id' => '58058',
5414 'payment_policy_id' => 'policy_payment_1234567890',
5415 'fulfillment_policy_id' => 'policy_fulfillment_1234567890',
5416 'return_policy_id' => 'policy_return_1234567890',
5417 'notification_destination' => 'https://domain.de/?dbx_modul=dbxShop&dbx_run1=channel_webhook&channel=ebay',
5418 'notification_topic' => 'ORDER',
5419 'webhook_secret' => 'verification_token_xxxx',
5420 'webhook_url' => 'wird von dbxShop erzeugt',
5421 'api_scope' => "https://api.ebay.com/oauth/api_scope/sell.inventory\nhttps://api.ebay.com/oauth/api_scope/sell.fulfillment\nhttps://api.ebay.com/oauth/api_scope/commerce.notification.subscription",
5422 ),
5423 'kleinanzeigen' => array(
5424 'channel_key' => 'kleinanzeigen',
5425 'api_base_url' => 'nur bei freigegebener Schnittstelle',
5426 'api_client_id' => 'partner_key_xxxx',
5427 'api_username' => 'api-user@example.de',
5428 'api_client_secret' => 'partner_secret_xxxx',
5429 'api_access_token' => 'access_token_xxxx',
5430 'api_refresh_token' => 'refresh_token_xxxx',
5431 'api_password' => 'password_xxxx',
5432 'marketplace_id' => 'DE',
5433 'seller_id' => 'seller_xxxx',
5434 'account_id' => 'account_xxxx',
5435 'location_key' => 'standort_10115_berlin',
5436 'category_id' => 'category_12345',
5437 'payment_policy_id' => 'nicht benoetigt',
5438 'fulfillment_policy_id' => 'nicht benoetigt',
5439 'return_policy_id' => 'nicht benoetigt',
5440 'notification_destination' => 'https://middleware.example.de/webhook',
5441 'notification_topic' => 'lead / message / sale',
5442 'webhook_secret' => 'middleware_secret_xxxx',
5443 'webhook_url' => 'wird von dbxShop erzeugt',
5444 'api_scope' => "laut Vertrag\nMiddleware-Berechtigung",
5445 ),
5446 'mobile' => array(
5447 'channel_key' => 'mobile',
5448 'api_base_url' => 'https://services.mobile.de/seller-api',
5449 'api_client_id' => 'nicht benoetigt bei Basic Auth',
5450 'api_username' => 'dealer_api_user_xxxx',
5451 'api_client_secret' => 'nicht benoetigt bei Basic Auth',
5452 'api_access_token' => 'nicht benoetigt bei Basic Auth',
5453 'api_refresh_token' => 'nicht benoetigt bei Basic Auth',
5454 'api_password' => 'dealer_api_password_xxxx',
5455 'marketplace_id' => 'DE',
5456 'seller_id' => 'customer_123456',
5457 'account_id' => 'mobileSellerId_123456',
5458 'location_key' => 'location_123456',
5459 'category_id' => 'car / motorbike / commercial',
5460 'payment_policy_id' => 'nicht benoetigt',
5461 'fulfillment_policy_id' => 'nicht benoetigt',
5462 'return_policy_id' => 'nicht benoetigt',
5463 'notification_destination' => 'https://middleware.example.de/mobile-leads',
5464 'notification_topic' => 'lead-api',
5465 'webhook_secret' => 'lead_secret_xxxx',
5466 'webhook_url' => 'wird von dbxShop erzeugt',
5467 'api_scope' => "seller-api\nbasic-auth\nlead-api",
5468 ),
5469 'custom' => array(
5470 'channel_key' => 'mein-channel',
5471 'api_base_url' => 'https://api.anbieter.de/v1',
5472 'api_client_id' => 'client_123456',
5473 'api_username' => 'api-user@example.de',
5474 'api_client_secret' => 'client_secret_xxxxx',
5475 'api_access_token' => 'access_token_xxxxx',
5476 'api_refresh_token' => 'refresh_token_xxxxx',
5477 'api_password' => 'API-Passwort',
5478 'marketplace_id' => 'DE',
5479 'seller_id' => 'seller_123456',
5480 'account_id' => 'account_123456',
5481 'location_key' => 'lager-1',
5482 'category_id' => '12345',
5483 'payment_policy_id' => 'payment_123',
5484 'fulfillment_policy_id' => 'shipping_123',
5485 'return_policy_id' => 'return_123',
5486 'notification_destination' => 'https://domain.de/webhook',
5487 'notification_topic' => 'order.created',
5488 'webhook_secret' => 'zufaelliges-geheimes-secret',
5489 'webhook_url' => 'wird von dbxShop erzeugt',
5490 'api_scope' => "products:write\norders:read\nwebhooks:read",
5491 ),
5492 );
5493 $placeholders = $placeholderMap[$platform] ?? $placeholderMap['custom'];
5494 $placeholderTranslations = array(
5495 'nicht benoetigt' => $texts->get_fd_message('channels_not_required'),
5496 'nicht benoetigt bei OAuth' => $texts->get_fd_message('channels_not_required_oauth'),
5497 'nicht benoetigt bei SP-API' => $texts->get_fd_message('channels_not_required_spapi'),
5498 'nicht benoetigt bei Basic Auth' => $texts->get_fd_message('channels_not_required_basic'),
5499 'wird von dbxShop erzeugt' => $texts->get_fd_message('channels_generated'),
5500 'nur bei freigegebener Schnittstelle' => $texts->get_fd_message('channels_approved_only'),
5501 );
5502 foreach ($placeholders as $placeholderField => $placeholderValue) {
5503 if (isset($placeholderTranslations[$placeholderValue])) {
5504 $placeholders[$placeholderField] = $placeholderTranslations[$placeholderValue];
5505 }
5506 }
5507 $ph = function (string $field) use ($placeholders): string {
5508 return $this->h($placeholders[$field] ?? '');
5509 };
5510 $secretPlaceholder = function (string $field) use ($placeholders): string {
5511 return $this->h((string)($placeholders[$field] ?? ''));
5512 };
5513 $status = (string)($channel['test_status'] ?? '');
5514 $statusBadge = $status === 'ok'
5515 ? '<span class="badge text-bg-success">' . $this->h($texts->get_fd_message('channels_status_ok')) . '</span>'
5516 : ($status === 'error'
5517 ? '<span class="badge text-bg-warning">' . $this->h($texts->get_fd_message('channels_status_open')) . '</span>'
5518 : '<span class="badge text-bg-secondary">' . $this->h($texts->get_fd_message('channels_status_none')) . '</span>');
5519 $activeBadge = (int)($channel['active'] ?? 1) === 1
5520 ? '<span class="badge text-bg-success">' . $this->h($texts->get_fd_message('active')) . '</span>'
5521 : '<span class="badge text-bg-secondary">' . $this->h($texts->get_fd_message('inactive')) . '</span>';
5522 $exportBadge = (int)($channel['export_enabled'] ?? 0) === 1 ? '<span class="badge text-bg-info">' . $this->h($texts->get_fd_message('channels_export')) . '</span>' : '';
5523 $orderBadge = (int)($channel['order_import_enabled'] ?? 0) === 1 ? '<span class="badge text-bg-primary">' . $this->h($texts->get_fd_message('channels_order_import')) . '</span>' : '';
5524 $webhookPath = ($key !== '' && $platform !== 'shop') ? '?dbx_modul=dbxShop&dbx_run1=channel_webhook&channel=' . rawurlencode($key) : '';
5525 $open = $isNew || (int)($_GET['edit'] ?? 0) === $id;
5526 $editUrl = '?dbx_modul=dbxShop_admin&dbx_run1=channels&edit=' . $id;
5527 $helpId = $this->ensureShopChannelProviderHelpPage($platform);
5528 $helpButton = '';
5529 if ($helpId > 0) {
5530 $helpTitle = 'Hilfe: Channel ' . ($platformOptions[$platform] ?? 'Channel');
5531 $helpButton = $this->openWinButton(
5532 '?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId,
5533 $helpTitle,
5534 '<i class="bi bi-question-circle"></i><span class="visually-hidden"> Hilfe</span>',
5535 'btn btn-outline-secondary btn-sm dbx-shop-channel-help',
5536 '72%',
5537 '82%'
5538 );
5539 $helpButton = str_replace('<a ', '<a data-dbx="lib=shopAdmin" data-shop-stop-propagation ', $helpButton);
5540 }
5541
5542 $form = dbx()->get_system_obj('dbxForm');
5543 $form->init('shop-channel-form-' . ($isNew ? 'new' : $id), 'shop-channel-form');
5544 $form->_dd = 'dbxShop|shopChannel';
5545 $form->_fd = 'dbxShop|shop-channel';
5546 $form->_data = $channel + array('id' => $id);
5547 $form->_rid = $isNew ? 0 : $id;
5548 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=channels' . ($isNew ? '&new=1' : '&edit=' . $id);
5549 $form->set_activ_id($isNew ? 0 : $id);
5550 $form->set_form_help_enabled(false);
5551 $form->add_rep('frame_skip_form_wrap', '0');
5552 $form->add_rep('form_class', 'dbx-shop-channel-dbXForm');
5553 $form->add_rep('form_attrs', 'data-target="dbxForm_{i}" data-dbx="lib=confirm|class=dbxConfirm|bind=button"');
5554 $form->add_rep('details_open', $open ? ' open' : '');
5555 $form->add_rep('channel_key_view', $this->h($key !== '' ? $key : strtolower($texts->get_fd_message('new'))));
5556 $form->add_rep('channel_title_view', $this->h($channel['title'] ?? $texts->get_fd_message('channels_new')));
5557 $form->add_rep('platform_view', $this->h($platformOptions[$platform] ?? $platform));
5558 $form->add_rep('connection_view', $this->h($modeOptions[(string)($channel['connection_mode'] ?? 'manual')] ?? ($channel['connection_mode'] ?? 'manual')));
5559 $form->add_rep('active_badge', $activeBadge);
5560 $form->add_rep('export_badge', $exportBadge);
5561 $form->add_rep('order_badge', $orderBadge);
5562 $form->add_rep('status_badge', $statusBadge);
5563 $form->add_rep('last_test_date', !empty($channel['last_test_date']) ? $this->h($channel['last_test_date']) : '');
5564 $form->add_rep('channel_help_button', $helpButton);
5565 $form->add_rep('channel_edit_button', !$isNew ? '<a class="btn btn-outline-primary btn-sm dbx-shop-channel-edit" data-dbx="lib=shopAdmin" data-shop-stop-propagation href="' . $this->h($editUrl) . '" title="' . $this->h($texts->get_fd_message('channels_edit_title')) . '"><i class="bi bi-pencil-square"></i> ' . $this->h($texts->get_fd_message('channels_edit')) . '</a>' : '');
5566 $form->add_rep('hint_api', $this->h($hint[0]));
5567 $form->add_rep('hint_listing', $this->h($hint[1]));
5568 $form->add_rep('hint_feedback', $this->h($hint[2]));
5569 $form->add_rep('test_message', !empty($channel['test_message']) ? '<div class="col-12"><div class="alert alert-secondary py-2 mb-0">' . $this->h($channel['test_message']) . '</div></div>' : '');
5570 $form->add_rep('test_button', !$isNew ? '<button class="btn btn-outline-secondary btn-sm ms-1" name="shop_action" value="test_channel" title="' . $this->h($texts->get_fd_message('channels_test_title')) . '"><i class="bi bi-plug"></i> ' . $this->h($texts->get_fd_message('channels_test_label')) . '</button>' : '');
5571 $form->add_rep('delete_button', !$isNew ? '<button type="submit" class="btn btn-outline-danger btn-sm ms-1 dbxConfirm" name="shop_action" value="delete_channel" title="' . $this->h($texts->get_fd_message('channels_delete_title')) . '" data-confirm-title="<i class=\'bi bi-trash\'></i> ' . $this->h($texts->get_fd_message('channels_delete_title')) . '" data-confirm="' . $this->h($texts->get_fd_message('channels_delete_confirm')) . '" data-confirm-hint="<small>' . $this->h($texts->get_fd_message('channels_delete_hint')) . '</small>" data-confirm-buttons="yesno"><i class="bi bi-trash"></i></button>' : '');
5572 $form->_msg_info = '';
5573
5574 $form->add_fld('id', tpl: 'dbx|hidden', rules: 'int', dd: 'dd::');
5575 $form->add_fld('channel_key', placeholder: $placeholders['channel_key'] ?? '', data: $isNew ? '' : 'readonly=readonly');
5576 $form->add_fld('title');
5577 $form->add_fld('platform_type', tpl: 'select-single-label', options: $platformOptions);
5578 $form->add_fld('connection_mode', tpl: 'select-single-label', options: $modeOptions);
5579 $form->add_fld('sorter');
5580 $form->add_fld('active');
5581 $form->add_fld('export_enabled');
5582 $form->add_fld('order_import_enabled');
5583 $form->add_fld('description', data: 'rows=2');
5584 $form->add_fld('api_base_url', placeholder: $placeholders['api_base_url'] ?? '');
5585 $form->add_fld('api_client_id', placeholder: $placeholders['api_client_id'] ?? '');
5586 $form->add_fld('api_username', placeholder: $placeholders['api_username'] ?? '');
5587 $form->add_fld('api_client_secret', placeholder: $secretPlaceholder('api_client_secret'));
5588 $form->add_fld('api_access_token', placeholder: $secretPlaceholder('api_access_token'));
5589 $form->add_fld('api_refresh_token', placeholder: $secretPlaceholder('api_refresh_token'));
5590 $form->add_fld('api_password', placeholder: $secretPlaceholder('api_password'));
5591 $form->add_fld('marketplace_id', placeholder: $placeholders['marketplace_id'] ?? '');
5592 $form->add_fld('seller_id', placeholder: $placeholders['seller_id'] ?? '');
5593 $form->add_fld('account_id', placeholder: $placeholders['account_id'] ?? '');
5594 $form->add_fld('location_key', placeholder: $placeholders['location_key'] ?? '');
5595 $form->add_fld('category_id', placeholder: $placeholders['category_id'] ?? '');
5596 $form->add_fld('payment_policy_id', placeholder: $placeholders['payment_policy_id'] ?? '');
5597 $form->add_fld('fulfillment_policy_id', placeholder: $placeholders['fulfillment_policy_id'] ?? '');
5598 $form->add_fld('return_policy_id', placeholder: $placeholders['return_policy_id'] ?? '');
5599 $form->add_fld('notification_destination', placeholder: $placeholders['notification_destination'] ?? '');
5600 $form->add_fld('notification_topic', placeholder: $placeholders['notification_topic'] ?? '');
5601 $form->add_fld('webhook_secret', placeholder: $secretPlaceholder('webhook_secret'));
5602 $form->add_fld('api_scope', placeholder: $placeholders['api_scope'] ?? '', data: 'rows=2');
5603 $form->add_obj('webhook_url', 'obj-value', '<label class="form-label">' . $this->h($texts->get_fd_message('channels_webhook_url')) . '</label><input class="form-control form-control-sm" value="' . $this->h($webhookPath) . '" placeholder="' . $ph('webhook_url') . '" readonly>');
5604
5605 return $form->run();
5606 };
5607
5608 $channels = $this->repo()->channels();
5609 $content = $notice . '<div class="m-3 dbx-shop-channel-list">';
5610 if ((int)($_GET['new'] ?? 0) === 1) {
5611 $sorter = 10;
5612 foreach ($channels as $channel) {
5613 $sorter = max($sorter, (int)($channel['sorter'] ?? 0) + 10);
5614 }
5615 $content .= $rowHtml(array(
5616 'title' => '',
5617 'platform_type' => 'custom',
5618 'connection_mode' => 'api',
5619 'export_enabled' => 1,
5620 'order_import_enabled' => 1,
5621 'active' => 1,
5622 'sorter' => $sorter,
5623 ), true);
5624 }
5625 foreach ($channels as $channel) {
5626 $content .= $rowHtml($channel);
5627 }
5628 $content .= '</div>';
5629
5630 $helpId = $this->ensureShopChannelsHelpPage();
5631 $helpButton = $helpId > 0
5632 ? $this->openWinButton('?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId, $texts->get_fd_message('channels_help'), '<i class="bi bi-question-circle"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('help')) . '</span>', 'btn btn-outline-secondary btn-sm me-1', '72%', '82%')
5633 : '';
5634 $barActions = '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=channels&new=1" title="' . $this->h($texts->get_fd_message('channels_new_title')) . '"><i class="bi bi-plus-square"></i><span class="visually-hidden"> ' . $this->h($texts->get_fd_message('channels_new')) . '</span></a>' . $helpButton;
5635 return $this->frame($content, $texts->get_fd_message('channels_title'), $barActions);
5636 }
5637
5638 public function run(): string {
5639 $run = dbx()->get_modul_var('dbx_run1', 'dashboard', 'parameter');
5640
5641 switch ($run) {
5642 case '':
5643 case 'dashboard':
5644 case 'start':
5645 return $this->dashboard();
5646
5647 case 'install':
5648 return $this->install();
5649
5650 case 'products':
5651 return $this->products();
5652
5653 case 'product_edit':
5654 return $this->productEdit();
5655
5656 case 'product_tree_move':
5657 return $this->productTreeMove();
5658
5659 case 'product_channel_mapping':
5660 return $this->productChannelMapping();
5661
5662 case 'products_help':
5663 return $this->productsHelp();
5664
5665 case 'groups':
5666 return $this->groups();
5667
5668 case 'attributes':
5669 return $this->attributes();
5670
5671 case 'product_attributes':
5672 return $this->productAttributes();
5673
5674 case 'shipping_groups':
5675 return $this->shippingGroups();
5676
5677 case 'channel_groups':
5678 return $this->channelGroups();
5679
5680 case 'channels':
5681 return $this->channels();
5682
5683 case 'media':
5684 return $this->media();
5685
5686 case 'assign_media':
5687 return $this->assignMedia();
5688
5689 case 'orders':
5690 return $this->orders();
5691
5692 case 'order_detail':
5693 return $this->orderDetail();
5694
5695 case 'order_invoice':
5696 return $this->orderInvoice();
5697
5698 case 'order_invoice_pdf':
5699 return $this->orderInvoicePdf();
5700
5701 case 'legal':
5702 return $this->shopLegalCmsPage(
5703 'legal',
5704 'Rechtstexte',
5705 'Diese Seite kommt aus dem CMS über den stabilen Permalink /shop-rechtstexte. Inhalte wie Anbieterkennzeichnung, AGB, Zahlung, Versand und Datenschutz-Hinweise werden dort gepflegt.',
5706 'legal'
5707 );
5708
5709 case 'returns':
5710 return $this->returns();
5711
5712 case 'settings':
5713 return $this->settings();
5714
5715 case 'payment_test':
5716 return $this->paymentTest();
5717
5718 default:
5719 return $this->placeholder('Unbekannter Shop-Aufruf', 'dbx_run1=' . $run . ' ist im Shop-Admin-Skeleton noch nicht definiert.');
5720 }
5721 }
5722}
5723?>
$cfg
static invalidateAllFullPages()
Menues sind Bestandteil jeder Full-Page-Ausgabe.
withdrawal_report_next_record($report, $record)
if((string)($formActionPolicy['action'] ?? '') !=='dbxAction.save'||!dbx() ->check_action_token((string)($formActionPolicy['scope'] ?? ''),(string)($formActionRoute['params']['dbx_token'] ?? ''))) $report
dbx()
Liefert die zentrale dbXapp-API als Singleton.
Definition dbxApi.php:2805
get_file_dir()
Liefert das files/-Verzeichnis der Installation.
Definition dbxApi.php:1524
search_defaults(array $overrides=array())
Standard-Optionen fuer dbx|search (wie Content-Grid).
Definition dbxApi.php:2326
dbx_lng_name(string $base, string $lng='')
Sprachsuffix an Basisnamen haengen: content + de => content_de.
Definition dbxApi.php:3085
action_token(string $scope='global')
Liefert ein sessiongebundenes Token fuer zustandsaendernde Link-Aktionen.
Definition dbxApi.php:1189
check_action_token(string $scope='global', string $token='')
Prueft ein sessiongebundenes Aktions-Token.
Definition dbxApi.php:1206
$_SERVER['REQUEST_METHOD']
if(!is_string($cms)||!is_string($process)) $checks
if(!is_object( $db)) if($db->connect_db_server($server) !==1) $created
if(preg_match('/core\.js\? foreach[^"\']*v=(\d+)/', $designTemplate, $assetMatch) !== 1 || !str_contains($shopReference, 'dbxapp-Asset-Version ' . $assetMatch[1])) foreach (array( 'reference\\archive', 'provision_docs_content.php', 'dbxSelfTest') as $needle)(array('Installation'=> $installation, 'Installations-Tutorial'=> $installationTutorial, 'SelfTest'=> $selfTest) as $label=> $html)
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
exit
Definition index.php:146
if( $demoId<=0) foreach(array('create_date', 'create_uid', 'update_date', 'update_uid', 'owner',) as $systemField) $items
DBX schema administration.
catch(RuntimeException $e) $amazonResult
$profile
Definition run.php:6
$titles
Definition update-seo.php:5