dbXapp 2.0
RAD, CMS, Module und Runtime-IDE fuer dbXapp
Loading...
Searching...
No Matches
dbxShopAdmin.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxShop_admin;
3
5
6 private function tpl() {
7 return dbx()->get_system_obj('dbxTPL');
8 }
9
10 private function db() {
11 return dbx()->get_system_obj('dbxDB');
12 }
13
14 private function h($value): string {
15 return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
16 }
17
18 private function openWinButton(string $url, string $title, string $content, string $class = 'btn btn-outline-primary', string $width = '88%', string $height = '88%'): string {
19 $escUrl = $this->h($url);
20 $escTitle = $this->h($title);
21 $class = trim($class);
22 if (strpos(' ' . $class . ' ', ' openWin ') === false) {
23 $class .= ' openWin';
24 }
25 if (strpos(' ' . $class . ' ', ' dbx-win ') === false) {
26 $class .= ' dbx-win';
27 }
28 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>';
29 }
30
31 private function helpButton(int $helpId, string $title, string $class = 'btn btn-outline-secondary btn-sm me-1', string $width = '72%', string $height = '82%'): string {
32 if ($helpId <= 0) {
33 return '';
34 }
35 return $this->openWinButton(
36 '?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId . '&dbx_ajax=1',
37 $title,
38 '<i class="bi bi-question-circle"></i><span class="visually-hidden"> Hilfe</span>',
39 $class,
40 $width,
41 $height
42 );
43 }
44
45 private function money($value): string {
46 return number_format((float) $value, 2, ',', '.') . ' EUR';
47 }
48
49 private function mediaUrl(string $path): string {
50 $path = trim(str_replace('\\', '/', $path));
51 if ($path === '') return '';
52 if (preg_match('~^https?://~i', $path) || substr($path, 0, 1) === '/') return $path;
53 return dbx()->get_base_url() . ltrim($path, '/');
54 }
55
56 private function mediaItemUrl(array $image, bool $thumb = true): string {
57 $mediaId = (int)($image['media_id'] ?? 0);
58 if ($mediaId > 0) {
59 $url = 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $mediaId;
60 if ($thumb) {
61 $url .= '&dbx_thumb=1';
62 }
63 return $url;
64 }
65 return $this->mediaUrl((string)($image['image_path'] ?? ''));
66 }
67
68 private function repo() {
69 return dbx()->get_include_obj('dbxShopRepository', 'dbxShop');
70 }
71
72 private function ensureSeed(): void {
73 $repo = $this->repo();
74 $repo->install();
75 if (count($repo->products(false)) === 0 || count($repo->shippingGroups()) === 0 || count($repo->channelGroups()) === 0 || count($repo->allImages()) === 0) {
76 $repo->seedDemoProducts();
77 }
78 }
79
80 private function shopMediaUsageContentId(): int {
81 $configured = (int)dbx()->get_config('dbxShop', 'media_usage_content_id');
82 if ($configured > 0 && $this->contentPageExists($configured)) {
83 return $configured;
84 }
85
86 return $this->ensureShopMediaUsagePage();
87 }
88
89 private function contentDd(): string {
90 return function_exists('dbx_lng_name') ? dbx_lng_name('content') : 'content_de';
91 }
92
93 private function folderDd(): string {
94 return function_exists('dbx_lng_name') ? dbx_lng_name('content_folder') : 'content_folder_de';
95 }
96
97 private function contentPageExists(int $contentId): bool {
98 if ($contentId <= 0) {
99 return false;
100 }
101 try {
102 $row = $this->db()->select1($this->contentDd(), $contentId, 'id', 0);
103 return is_array($row) && (int)($row['id'] ?? 0) === $contentId;
104 } catch (\Throwable $e) {
105 return false;
106 }
107 }
108
109 private function ensureShopHelpFolder(): int {
110 $db = $this->db();
111 $folderDd = $this->folderDd();
112 $row = $db->select1($folderDd, array('name' => 'shop', 'parent_id' => 0), 'id', 0);
113 if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
114 return (int)$row['id'];
115 }
116
117 $folderId = 0;
118 $folderOk = (int)$db->insert($folderDd, array(
119 'name' => 'shop',
120 'parent_id' => 0,
121 'sorter' => '9000',
122 'group_read' => 'admin',
123 'template' => 'c-content',
124 'hero_template' => 'image-hero',
125 'hero_image_id' => '',
126 'hero_margin_top' => '',
127 'hero_height' => '',
128 'hero_variant' => '',
129 'hero_sticky' => '',
130 'hero_scroll_layer' => '',
131 ));
132 if ($folderOk === 1) {
133 $folderId = (int)$db->get_insert_id();
134 }
135 if ($folderId > 0) {
136 return $folderId;
137 }
138
139 return 0;
140 }
141
142 private function channelGroupsHelpHtml(): string {
143 return '<section class="dbx-shop-help">'
144 . '<h2>Channel-Gruppen</h2>'
145 . '<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>'
146 . '<h3>Eingabefelder</h3>'
147 . '<dl>'
148 . '<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>'
149 . '<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>'
150 . '<dt>Beschreibung</dt><dd>Interne Erklaerung der Gruppe. Tragen Sie ein, welche Artikel in diese Gruppe gehoeren und warum die ausgewaehlten Channels passen.</dd>'
151 . '<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>'
152 . '<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>'
153 . '<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>'
154 . '</dl>'
155 . '<h3>Aktionen</h3>'
156 . '<ul>'
157 . '<li><strong>Plus in der Bar:</strong> fuegt oben eine leere Eingabezeile fuer eine neue Channel-Gruppe ein.</li>'
158 . '<li><strong>Speichern:</strong> uebernimmt Name, Beschreibung, Channel-Auswahl, Sortierung und Aktiv-Status.</li>'
159 . '<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>'
160 . '</ul>'
161 . '<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>'
162 . '</section>';
163 }
164
165 private function channelsHelpHtml(): string {
166 return '<section class="dbx-shop-help">'
167 . '<h2>Channels</h2>'
168 . '<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>'
169 . '<h3>Eingabefelder</h3>'
170 . '<dl>'
171 . '<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>'
172 . '<dt>Channel</dt><dd>Sichtbarer Name in Verwaltung, Artikelliste und Channel-Gruppen.</dd>'
173 . '<dt>Beschreibung</dt><dd>Interne Notiz, wofuer der Channel genutzt wird und welche Besonderheiten bei Angeboten gelten.</dd>'
174 . '<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>'
175 . '<dt>Verbindung</dt><dd>Legt fest, ob der Channel intern, manuell, per API oder per Feed/Webhook betrieben wird.</dd>'
176 . '<dt>Export</dt><dd>Erlaubt, Artikel aus dem Shop fuer diesen Channel vorzubereiten oder spaeter automatisiert als Listing zu uebertragen.</dd>'
177 . '<dt>Order-Import</dt><dd>Erlaubt Bestellrueckmeldungen vom Channel in den Shop. Externe Plattformen benoetigen dafuer API-Zugriff oder einen Webhook.</dd>'
178 . '<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>'
179 . '<dt>Client-ID / Benutzer</dt><dd>Oeffentliche Kennung oder Login-Name der Integration.</dd>'
180 . '<dt>Secret / Token / Passwort</dt><dd>Zugangsdaten fuer API-Aufrufe. Gespeicherte Werte werden im Feld angezeigt und koennen direkt ueberschrieben werden.</dd>'
181 . '<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>'
182 . '<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>'
183 . '<dt>Location-Key</dt><dd>Lager-, Standort- oder Versandortkennung. eBay benoetigt einen Merchant Location Key, bevor Angebote veroeffentlicht werden koennen.</dd>'
184 . '<dt>Kategorie-ID</dt><dd>Standard-Kategorie fuer neue Listings, wenn der Adapter keine automatische Kategorieermittlung nutzt.</dd>'
185 . '<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>'
186 . '<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>'
187 . '<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>'
188 . '<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>'
189 . '<dt>Sort / Aktiv</dt><dd>Sortierung und grundsaetzliche Freigabe des Channels.</dd>'
190 . '</dl>'
191 . '<h3>Aktionen</h3>'
192 . '<ul>'
193 . '<li><strong>Plus in der Bar:</strong> oeffnet ein Formular fuer einen neuen Channel.</li>'
194 . '<li><strong>Speichern:</strong> uebernimmt Stammdaten, Integrationsmodus und Zugangsdaten.</li>'
195 . '<li><strong>Test:</strong> prueft provider-spezifisch die Pflichtfelder und, soweit Zugangsdaten vorhanden sind, einen echten API-Zugriff ohne Daten zu veraendern.</li>'
196 . '<li><strong>Loeschen:</strong> deaktiviert und verschiebt den Channel in den Papierkorb. Artikelzuordnungen werden deaktiviert.</li>'
197 . '</ul>'
198 . '<p><strong>Praxis:</strong> Erst Channel konfigurieren, dann in Channel-Gruppen auswaehlen und anschliessend Artikel diesen Gruppen oder einzelnen Channels zuordnen.</p>'
199 . '</section>';
200 }
201
202 private function channelProviderHelpHtml(string $platform): string {
203 $common = '<h3>Gemeinsame Eingabefelder</h3>'
204 . '<dl>'
205 . '<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>'
206 . '<dt>Channel</dt><dd>Sichtbarer Name in der Verwaltung.</dd>'
207 . '<dt>Plattform</dt><dd>Legt fest, welcher Adapter oder welche Arbeitsweise spaeter verwendet wird.</dd>'
208 . '<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>'
209 . '<dt>Aktiv</dt><dd>Nur aktive Channels koennen Artikeln zugeordnet und fuer Importe verwendet werden.</dd>'
210 . '<dt>Export</dt><dd>Erlaubt, Artikel fuer diesen Channel vorzubereiten oder spaeter automatisiert zur Plattform zu uebertragen.</dd>'
211 . '<dt>Order-Import</dt><dd>Erlaubt, Bestellungen oder Kauf-/Lead-Rueckmeldungen vom Channel in dbxShop zu uebernehmen.</dd>'
212 . '<dt>API-Basis-URL</dt><dd>Basisadresse der Anbieter-API. Der Verbindungstest nutzt den passenden Connector und macht nur lesende Pruefungen.</dd>'
213 . '<dt>Client-ID</dt><dd>Oeffentliche App- oder Client-Kennung der Plattform.</dd>'
214 . '<dt>Client-Secret</dt><dd>Geheimer App-Schluessel. Der gespeicherte Wert wird angezeigt und kann direkt ersetzt werden.</dd>'
215 . '<dt>Access-Token</dt><dd>Kurzlebiger Zugriffstoken. Wird oft automatisch aus dem Refresh-Token erneuert.</dd>'
216 . '<dt>Refresh-Token</dt><dd>Langfristiger Token, mit dem neue Access-Tokens erzeugt werden.</dd>'
217 . '<dt>Benutzer / Passwort</dt><dd>Nur fuer Plattformen mit Basic Auth oder klassischem API-Login verwenden.</dd>'
218 . '<dt>Marketplace-ID</dt><dd>Marktplatz- oder Laenderkennung, zum Beispiel <code>EBAY_DE</code> oder <code>A1PA6795UKMFR9</code>.</dd>'
219 . '<dt>Seller-ID</dt><dd>Verkaeuferkennung der Plattform.</dd>'
220 . '<dt>Account-ID / mobileSellerId</dt><dd>Konto-, Haendler- oder mobile.de Seller-ID.</dd>'
221 . '<dt>Location-Key</dt><dd>Standort- oder Lagerkennung, relevant fuer eBay-Angebote.</dd>'
222 . '<dt>Kategorie-ID</dt><dd>Plattformkategorie fuer Listings, falls keine automatische Zuordnung genutzt wird.</dd>'
223 . '<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>'
224 . '<dt>Notification-Ziel / SQS ARN</dt><dd>Zieladresse fuer Ereignisse: HTTPS-Destination bei eBay oder Amazon SQS ARN bei Amazon.</dd>'
225 . '<dt>Notification-Topic</dt><dd>Ereignistyp, der abonniert wird, zum Beispiel <code>ORDER_CHANGE</code>.</dd>'
226 . '<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>'
227 . '<dt>Webhook-Secret</dt><dd>Geheimer Wert, mit dem dbxShop eingehende Channel-Webhooks pruefen kann.</dd>'
228 . '<dt>dbxShop Webhook-URL</dt><dd>URL, die eine Plattform oder Middleware fuer Rueckmeldungen aufrufen kann.</dd>'
229 . '</dl>';
230
231 $linksTitle = '<h3>Beantragen / Bearbeiten</h3>';
232 switch ($platform) {
233 case 'ebay':
234 return '<section class="dbx-shop-help">'
235 . '<h2>Channel-Hilfe: eBay</h2>'
236 . '<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>'
237 . $common
238 . '<h3>eBay-spezifisch</h3><ol>'
239 . '<li>Developer-Konto anlegen und Application Keyset erstellen. Daraus kommen Client-ID und Client-Secret.</li>'
240 . '<li>OAuth User Consent fuer den eBay-Verkaeufer durchlaufen. Daraus kommt der Refresh-Token.</li>'
241 . '<li>Scopes fuer Inventory, Fulfillment und Notifications eintragen.</li>'
242 . '<li>Marketplace-ID setzen, fuer Deutschland meist <code>EBAY_DE</code>.</li>'
243 . '<li>Merchant Location Key anlegen oder aus der Inventory API uebernehmen.</li>'
244 . '<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>'
245 . '<li><strong>Payment Policy ID:</strong> ID der Zahlungsregel, z.B. welche Zahlungsarten erlaubt sind und wann bezahlt werden muss.</li>'
246 . '<li><strong>Fulfillment Policy ID:</strong> ID der Versandregel, z.B. Versanddienst, Bearbeitungszeit, Kosten, kostenlose Lieferung, internationale Versandzonen.</li>'
247 . '<li><strong>Return Policy ID:</strong> ID der Rueckgaberegel, z.B. ob Rueckgabe akzeptiert wird, Frist und wer Ruecksendekosten traegt.</li>'
248 . '<li>Notification Destination auf die dbxShop Webhook-URL setzen und Topic/Subscription fuer Order-Ereignisse konfigurieren.</li>'
249 . '</ol>'
250 . '<h3>eBay Scopes richtig eintragen</h3>'
251 . '<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>'
252 . '<p>Beispiel:</p>'
253 . '<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>'
254 . $linksTitle
255 . '<ul>'
256 . '<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>'
257 . '<li><a href="https://developer.ebay.com/develop/guides-v2/authorization" target="_blank" rel="noopener">eBay OAuth und Scopes</a></li>'
258 . '<li><a href="https://developer.ebay.com/api-docs/sell/inventory/overview.html" target="_blank" rel="noopener">eBay Inventory API</a></li>'
259 . '<li><a href="https://developer.ebay.com/api-docs/sell/fulfillment/overview.html" target="_blank" rel="noopener">eBay Fulfillment API / Orders</a></li>'
260 . '<li><a href="https://developer.ebay.com/api-docs/commerce/notification/overview.html" target="_blank" rel="noopener">eBay Notification API</a></li>'
261 . '<li><a href="https://www.ebay.com/help/selling/business-policies/business-policies?id=4212" target="_blank" rel="noopener">eBay Business Policies</a></li>'
262 . '</ul></section>';
263
264 case 'amazon':
265 return '<section class="dbx-shop-help">'
266 . '<h2>Channel-Hilfe: Amazon</h2>'
267 . '<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>'
268 . $common
269 . '<h3>Amazon-spezifisch</h3><ol>'
270 . '<li>Professionelles Seller-Central-Konto und Entwicklerzugang vorbereiten.</li>'
271 . '<li>SP-API Anwendung im Solution Provider Portal registrieren.</li>'
272 . '<li>LWA Client-ID und Client-Secret aus der App-Registrierung uebernehmen.</li>'
273 . '<li>Seller-Autorisierung durchlaufen und Refresh-Token speichern.</li>'
274 . '<li>Marketplace-ID setzen, fuer Amazon.de <code>A1PA6795UKMFR9</code>.</li>'
275 . '<li>Seller-ID aus Seller Central oder aus der Autorisierung uebernehmen.</li>'
276 . '<li>Rollen fuer Product Listing, Orders und Notifications beantragen und im Feld API-Scopes/Rollen dokumentieren.</li>'
277 . '<li>Fuer Rueckmeldungen Amazon Notifications API mit SQS Destination einrichten; die SQS ARN im Notification-Ziel speichern.</li>'
278 . '</ol>'
279 . '<h3>Amazon-Felder verstaendlich erklaert</h3><dl>'
280 . '<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>'
281 . '<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>'
282 . '<dt>Marketplace-ID</dt><dd>Legt fest, auf welchem Amazon-Marktplatz gearbeitet wird. Fuer Deutschland ist das typischerweise <code>A1PA6795UKMFR9</code>.</dd>'
283 . '<dt>Seller-ID</dt><dd>Identifiziert das Seller-Konto, fuer das Listings und Orders verarbeitet werden.</dd>'
284 . '<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>'
285 . '<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>'
286 . '<dt>Notification-Topic</dt><dd>Fuer Bestellungen ist <code>ORDER_CHANGE</code> relevant. Damit koennen Statusaenderungen oder neue Bestellungen in dbxShop ankommen.</dd>'
287 . '<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>'
288 . '<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>'
289 . '</dl>'
290 . $linksTitle
291 . '<ul>'
292 . '<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>'
293 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/authorization-overview" target="_blank" rel="noopener">Amazon SP-API Autorisierung</a></li>'
294 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/marketplace-ids" target="_blank" rel="noopener">Amazon Marketplace-IDs</a></li>'
295 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/listings-items-api" target="_blank" rel="noopener">Amazon Listings Items API</a></li>'
296 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/orders-api-v0-reference" target="_blank" rel="noopener">Amazon Orders API</a></li>'
297 . '<li><a href="https://developer-docs.amazon.com/sp-api/docs/notifications-api" target="_blank" rel="noopener">Amazon Notifications API</a></li>'
298 . '</ul></section>';
299
300 case 'mobile':
301 return '<section class="dbx-shop-help">'
302 . '<h2>Channel-Hilfe: mobile.de</h2>'
303 . '<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>'
304 . $common
305 . '<h3>mobile.de-spezifisch</h3><ol>'
306 . '<li>Dealer- oder API-Account bei mobile.de verwenden.</li>'
307 . '<li>API-Zugang im Dealer-Bereich oder ueber mobile.de Support aktivieren lassen.</li>'
308 . '<li>Benutzer und Passwort fuer Basic Auth eintragen.</li>'
309 . '<li>API-Basis-URL setzen, produktiv normalerweise <code>https://services.mobile.de</code> bzw. Seller-API-Pfad.</li>'
310 . '<li>Account-ID/mobileSellerId oder Customer Number aus Dealer Area/API-Daten uebernehmen.</li>'
311 . '<li>Fuer Rueckmeldungen Lead API nutzen; passende Lead-Events im Notification-Topic dokumentieren.</li>'
312 . '</ol>'
313 . '<h3>mobile.de-Felder verstaendlich erklaert</h3><dl>'
314 . '<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>'
315 . '<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>'
316 . '<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>'
317 . '<dt>Seller-ID</dt><dd>Kann als Kundennummer oder interne Haendlerkennung dokumentiert werden.</dd>'
318 . '<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>'
319 . '<dt>Location-Key</dt><dd>Optionaler Standort oder Filialbezug, wenn ein Haendler mehrere Standorte hat.</dd>'
320 . '<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>'
321 . '<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>'
322 . '<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>'
323 . '</dl>'
324 . $linksTitle
325 . '<ul>'
326 . '<li><a href="https://services.mobile.de/" target="_blank" rel="noopener">mobile.de API Uebersicht und Accounts</a></li>'
327 . '<li><a href="https://services.mobile.de/manual/seller-api.html" target="_blank" rel="noopener">mobile.de Seller API</a></li>'
328 . '<li><a href="https://services.sandbox.mobile.de/manual/lead-api.html" target="_blank" rel="noopener">mobile.de Lead API Sandbox</a></li>'
329 . '<li><a href="https://services.mobile.de/manual/search-api.html" target="_blank" rel="noopener">mobile.de Search API / Customer IDs</a></li>'
330 . '</ul></section>';
331
332 case 'kleinanzeigen':
333 return '<section class="dbx-shop-help">'
334 . '<h2>Channel-Hilfe: Kleinanzeigen</h2>'
335 . '<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>'
336 . $common
337 . '<h3>Kleinanzeigen-spezifisch</h3><ol>'
338 . '<li>Wenn keine vertragliche Schnittstelle vorhanden ist, Verbindung auf <code>Manuell</code> lassen.</li>'
339 . '<li>Export nur aktivieren, wenn eine rechtlich freigegebene Middleware oder Schnittstelle vorhanden ist.</li>'
340 . '<li>API-Basis-URL, Benutzer, Token und Kategorie-ID nur aus dieser freigegebenen Schnittstelle uebernehmen.</li>'
341 . '<li>Order-Import nur aktivieren, wenn Kauf-/Kontakt-Rueckmeldungen offiziell oder ueber eine eigene Middleware geliefert werden.</li>'
342 . '<li>Fuer Immobilien gibt es offizielle Importschnittstellen; diese sind nicht automatisch eine allgemeine Artikel-API.</li>'
343 . '</ol>'
344 . '<h3>Kleinanzeigen-Felder verstaendlich erklaert</h3><dl>'
345 . '<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>'
346 . '<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>'
347 . '<dt>Kategorie-ID</dt><dd>Falls eine freigegebene Schnittstelle Kategorien verwendet, kann hier die Zielkategorie dokumentiert werden.</dd>'
348 . '<dt>Location-Key</dt><dd>Kann Ort, PLZ oder Standort-ID enthalten, wenn die Schnittstelle das erwartet.</dd>'
349 . '<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>'
350 . '<dt>Order-Import</dt><dd>Nur aktivieren, wenn wirklich strukturierte Kauf-/Kontakt-Rueckmeldungen an dbxShop uebergeben werden koennen. Sonst bleibt die Pflege manuell.</dd>'
351 . '<dt>Payment/Fulfillment/Return Policies</dt><dd>Diese eBay-Felder sind fuer Kleinanzeigen normalerweise nicht relevant.</dd>'
352 . '<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>'
353 . '</dl>'
354 . $linksTitle
355 . '<ul>'
356 . '<li><a href="https://hilfe-gewerblich.kleinanzeigen.de/artikel/schnittstellen" target="_blank" rel="noopener">Kleinanzeigen gewerbliche Schnittstellen</a></li>'
357 . '<li><a href="https://business.kleinanzeigen.de/" target="_blank" rel="noopener">Kleinanzeigen fuer Gewerbliche</a></li>'
358 . '</ul></section>';
359
360 case 'shop':
361 return '<section class="dbx-shop-help">'
362 . '<h2>Channel-Hilfe: Shop</h2>'
363 . '<p>Der Shop-Channel ist der interne Verkaufskanal. Er braucht keine externen API-Zugangsdaten.</p>'
364 . $common
365 . '<h3>Shop-spezifisch</h3><ol>'
366 . '<li>Verbindung auf <code>Intern</code> lassen.</li>'
367 . '<li>Export aktiv lassen, wenn Artikel im eigenen Shop sichtbar sein duerfen.</li>'
368 . '<li>Order-Import bleibt aus, weil Bestellungen direkt im Shop entstehen.</li>'
369 . '<li>API-Felder, Token, Marketplace-ID und Policies sind fuer den internen Shop nicht benoetigt.</li>'
370 . '</ol></section>';
371
372 default:
373 return '<section class="dbx-shop-help">'
374 . '<h2>Channel-Hilfe: Eigener Channel</h2>'
375 . '<p>Eigene Channels koennen an individuelle APIs, Feed-Exporte oder Middleware angebunden werden. Die konkreten Pflichtwerte haengen vom Adapter ab.</p>'
376 . $common
377 . '<h3>Vorgehen</h3><ol>'
378 . '<li>Beim Anbieter API-Zugang, Dokumentation und Testumgebung beantragen.</li>'
379 . '<li>API-Basis-URL, Authentifizierung und erforderliche IDs in die Felder uebernehmen.</li>'
380 . '<li>Webhook-Secret setzen und den dbxShop Webhook in der Middleware hinterlegen.</li>'
381 . '<li>Verbindung testen und erst danach Export oder Order-Import produktiv nutzen.</li>'
382 . '</ol></section>';
383 }
384 }
385
386 private function productGroupsHelpHtml(): string {
387 return '<section class="dbx-shop-help">'
388 . '<h2>Artikelgruppen</h2>'
389 . '<p>Artikelgruppen beschreiben fachliche Produktfamilien. Sie steuern Standardwerte wie Mehrwertsteuer, Darstellung, Detailansicht, Galerieverhalten und Hinweise zu Artikelattributen.</p>'
390 . '<h3>Eingabefelder</h3>'
391 . '<dl>'
392 . '<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>'
393 . '<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>'
394 . '<dt>Gruppe</dt><dd>Sichtbarer Name der Artikelgruppe in Verwaltung und Auswahllisten.</dd>'
395 . '<dt>Beschreibung</dt><dd>Interne Beschreibung, welche Artikel in diese Gruppe gehoeren und wie sie im Shop verwendet wird.</dd>'
396 . '<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>'
397 . '<dt>MwSt.</dt><dd>Standard-Mehrwertsteuersatz fuer Artikel dieser Gruppe. Einzelne Artikel koennen abweichen, wenn sie eigene Steuerwerte verwenden.</dd>'
398 . '<dt>Karte</dt><dd>Template fuer Produktkarten in Listen, Katalogen oder Teasern.</dd>'
399 . '<dt>Detail</dt><dd>Template fuer die Detailseite eines Artikels aus dieser Gruppe.</dd>'
400 . '<dt>Gallery</dt><dd>Template fuer die Bild- oder Mediengalerie der Gruppe.</dd>'
401 . '<dt>Bilder</dt><dd>Anzahl der initial sichtbaren Galeriebilder. Weitere Bilder koennen je nach Overflow-Einstellung sichtbar, scrollbar oder per Galerie erreichbar sein.</dd>'
402 . '<dt>Bildgroesse</dt><dd>Steuert, wie Bilder in der Galerie eingepasst werden: Original, Cover oder Contain.</dd>'
403 . '<dt>Lightbox</dt><dd>Breite der Lightbox-Ansicht, zum Beispiel <code>100vw</code> fuer volle Viewport-Breite.</dd>'
404 . '<dt>Overflow</dt><dd>Verhalten bei vielen Bildern, zum Beispiel Grid, Slider, Scroll, Laufband oder Tutorial.</dd>'
405 . '<dt>Klick</dt><dd>Definiert, was beim Klick auf ein Bild passiert: Lightbox, kein Klick, neuer Tab, ViewerJS oder PhotoSwipe.</dd>'
406 . '<dt>Artikelattribute</dt><dd>Interne Hinweise fuer Attribute dieser Gruppe, zum Beispiel welche technischen Daten, Varianten oder Filter erwartet werden.</dd>'
407 . '<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>'
408 . '<dt>Sort</dt><dd>Reihenfolge in Listen und Auswahlfeldern. Kleine Zahlen stehen weiter oben; Schritte von 10 lassen Platz fuer spaetere Gruppen.</dd>'
409 . '<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>'
410 . '</dl>'
411 . '<h3>Aktionen</h3>'
412 . '<ul>'
413 . '<li><strong>Plus in der Bar:</strong> legt oben eine neue Eingabezeile fuer eine Artikelgruppe an.</li>'
414 . '<li><strong>Speichern:</strong> uebernimmt alle sichtbaren Einstellungen der Zeile.</li>'
415 . '<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>'
416 . '</ul>'
417 . '<p><strong>Praxis:</strong> Verwenden Sie Artikelgruppen fuer wiederkehrende Produktlogik, nicht fuer einzelne Produkte. Beispiele sind Software, Merchandise, Service oder Zubehoer.</p>'
418 . '<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>'
419 . '</section>';
420 }
421
422 private function shippingGroupsHelpHtml(): string {
423 return '<section class="dbx-shop-help">'
424 . '<h2>Versandgruppen</h2>'
425 . '<p>Versandgruppen definieren Versandweg, Lieferzeit, Standardkosten und Freigrenzen. Artikel koennen diese Werte aus der Gruppe uebernehmen oder eigene Versandwerte verwenden.</p>'
426 . '<h3>Eingabefelder</h3>'
427 . '<dl>'
428 . '<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>'
429 . '<dt>Versandgruppe</dt><dd>Sichtbarer Name fuer Verwaltung und Auswahlfelder.</dd>'
430 . '<dt>Beschreibung</dt><dd>Interne Erklaerung, fuer welche Artikel und Liefersituationen diese Versandgruppe gedacht ist.</dd>'
431 . '<dt>Versandweg</dt><dd>Bezeichnung des Versand- oder Bereitstellungswegs, zum Beispiel Download, Remote-Termin, DHL Paket oder Spedition.</dd>'
432 . '<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>'
433 . '<dt>Kosten</dt><dd>Standard-Versandkosten brutto. Verwenden Sie <code>0</code> fuer kostenfreie Lieferung oder digitale Bereitstellung.</dd>'
434 . '<dt>Frei ab</dt><dd>Brutto-Warenwert, ab dem die Versandkosten entfallen. Der Wert <code>-1</code> bedeutet: keine Freigrenze.</dd>'
435 . '<dt>Sort</dt><dd>Reihenfolge in Listen und Auswahlfeldern. Kleine Zahlen stehen weiter oben.</dd>'
436 . '<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>'
437 . '</dl>'
438 . '<h3>Aktionen</h3>'
439 . '<ul>'
440 . '<li><strong>Plus in der Bar:</strong> legt oben eine neue Eingabezeile fuer eine Versandgruppe an.</li>'
441 . '<li><strong>Speichern:</strong> uebernimmt Versandweg, Lieferzeit, Kosten, Freigrenze, Sortierung und Aktiv-Status.</li>'
442 . '<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>'
443 . '</ul>'
444 . '<p><strong>Praxis:</strong> Trennen Sie digitale Bereitstellung, Remote-Leistungen und physische Paketlieferungen in eigene Versandgruppen.</p>'
445 . '</section>';
446 }
447
448 private function settingsHelpHtml(): string {
449 return '<section class="dbx-shop-help">'
450 . '<h2>Shop-Einstellungen</h2>'
451 . '<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>'
452 . '<h3>Shop</h3><dl>'
453 . '<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>'
454 . '<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>'
455 . '<dt>Standard-Waehrung</dt><dd>ISO-Waehrungscode fuer Preise und Bestellungen, z.B. <code>EUR</code>. Der Wert sollte aus drei Buchstaben bestehen.</dd>'
456 . '<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>'
457 . '<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>'
458 . '<dt>Lagerbestand nutzen</dt><dd>Aktiviert die Beruecksichtigung des Lagerbestands. Wenn aus, koennen Artikel unabhaengig vom Feld Lagerbestand angezeigt werden.</dd>'
459 . '<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>'
460 . '</dl>'
461 . '<h3>MwSt.</h3><dl>'
462 . '<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>'
463 . '<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>'
464 . '<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>'
465 . '<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>'
466 . '<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>'
467 . '<dt>mwst2 - Prozent</dt><dd>Prozentwert der zweiten Steuerklasse, aktuell typischerweise <code>7</code>.</dd>'
468 . '<dt>mwst3 - Name</dt><dd>Dritte vorbereitete Steuerklasse. Sie kann fuer kuenftige Steuersaetze, Sonderfaelle oder Uebergangsregeln genutzt werden.</dd>'
469 . '<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>'
470 . '<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>'
471 . '</dl>'
472 . '<h3>Checkout und Rechtstexte</h3><dl>'
473 . '<dt>Gastbestellung erlauben</dt><dd>Erlaubt Bestellungen ohne vorheriges Kundenkonto. Wenn deaktiviert, verlangt der Checkout ein angemeldetes Benutzerkonto.</dd>'
474 . '<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>'
475 . '<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>'
476 . '<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>'
477 . '<dt>Admin-Mail senden</dt><dd>Sendet bei neuen Bestellungen und Widerrufen eine interne Benachrichtigung an die Admin-E-Mail-Adresse.</dd>'
478 . '<dt>Mail-Absender</dt><dd>Absenderadresse fuer Shop-Mails. Verwenden Sie eine echte Domain-Adresse, die in der Mail-Konfiguration erlaubt ist, z.B. <code>shop@example.org</code>.</dd>'
479 . '<dt>Admin-E-Mail</dt><dd>Empfaengeradresse fuer interne Shop-Benachrichtigungen, z.B. <code>admin@example.org</code>.</dd>'
480 . '</dl>'
481 . '<h3>Zahlungsarten</h3><dl>'
482 . '<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>'
483 . '<dt>Kontoinhaber</dt><dd>Name des Zahlungsempfaengers fuer Vorkasse, z.B. Firmenname oder Shop-Betreiber.</dd>'
484 . '<dt>IBAN</dt><dd>Bankverbindung fuer Vorkasse. Dieser Wert wird Kunden nach der Bestellung und in der Bestellmail als Zahlungsinformation angezeigt.</dd>'
485 . '<dt>BIC</dt><dd>BIC/SWIFT der Bank. Innerhalb Deutschlands oft nicht zwingend, fuer internationale Kunden aber hilfreich.</dd>'
486 . '<dt>Bank</dt><dd>Name der Bank fuer die Vorkasse-Zahlungsinformation.</dd>'
487 . '<dt>Vorkasse-Hinweis</dt><dd>Text, der Kunden erklaert, wann und wie sie ueberweisen sollen. Die Bestellnummer wird zusaetzlich als Verwendungszweck angezeigt.</dd>'
488 . '<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>'
489 . '<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>'
490 . '<dt>PayPal aktiv</dt><dd>Schaltet PayPal im Checkout frei. PayPal funktioniert erst, wenn Modus, Client-ID und Client-Secret korrekt eingetragen sind.</dd>'
491 . '<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>'
492 . '<dt>PayPal Brand Name</dt><dd>Name, der bei PayPal im Zahlungsfenster erscheinen soll, z.B. der Shop- oder Firmenname.</dd>'
493 . '<dt>PayPal Client-ID</dt><dd>Oeffentliche App-Kennung aus dem PayPal Developer Dashboard. Fuer Sandbox und Live gibt es getrennte Zugangsdaten.</dd>'
494 . '<dt>PayPal Client-Secret</dt><dd>Geheimer App-Schluessel aus dem PayPal Developer Dashboard. Dieser Wert darf nicht oeffentlich angezeigt oder weitergegeben werden.</dd>'
495 . '<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>'
496 . '<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>'
497 . '<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>'
498 . '<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>'
499 . '<dt>Amazon Pay Region</dt><dd>Region des Amazon-Pay-Kontos. Fuer Deutschland typischerweise <code>EU</code>.</dd>'
500 . '<dt>Amazon Merchant-ID</dt><dd>Haendlerkennung aus der Amazon-Pay-/Seller-Central-Konfiguration.</dd>'
501 . '<dt>Amazon Store-ID</dt><dd>Store-/Checkout-Konfiguration aus Amazon Pay. Sie identifiziert, welche Store-Konfiguration fuer den Checkout genutzt werden soll.</dd>'
502 . '<dt>Public-Key-ID</dt><dd>Kennung des oeffentlichen Schluessels, der bei Amazon Pay hinterlegt wurde.</dd>'
503 . '<dt>Private Key</dt><dd>Privater Signaturschluessel fuer Amazon Pay. Dieser Wert ist geheim und darf nicht oeffentlich angezeigt werden.</dd>'
504 . '<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>'
505 . '<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>'
506 . '</dl>'
507 . '<h3>Channels</h3><dl>'
508 . '<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>'
509 . '<dt>Channels bearbeiten</dt><dd>Oeffnet die Channel-Verwaltung. Dort werden API-Zugangsdaten, Marketplace-, Seller-, Location- und Policy-Werte gepflegt.</dd>'
510 . '</dl>'
511 . '<h3>Versand</h3><dl>'
512 . '<dt>Digitale Downloads aktiv</dt><dd>Erlaubt digitale Bereitstellung fuer passende Produkte. Digitale Produkte koennen andere Widerrufsregeln haben; die Rechtstexte muessen dazu passen.</dd>'
513 . '<dt>Pauschalversand aktiv</dt><dd>Aktiviert einen einfachen globalen Versandkostenwert als Fallback. Detailliertere Versandlogik wird weiterhin ueber Versandgruppen gepflegt.</dd>'
514 . '<dt>Pauschalversand brutto</dt><dd>Globaler Bruttobetrag fuer Versandkosten, wenn keine spezifischere Versandgruppe greift. Beispiel: <code>5.90</code>.</dd>'
515 . '<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>'
516 . '</dl>'
517 . '<h3>Speichern</h3>'
518 . '<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>'
519 . '</section>';
520 }
521
522 private function ordersHelpHtml(): string {
523 return '<section class="dbx-shop-help">'
524 . '<h2>Bestellungen</h2>'
525 . '<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>'
526 . '<h3>Liste</h3><dl>'
527 . '<dt>Bestellung</dt><dd>Bestellnummer, Datum und Herkunft. Die Bestellnummer bleibt stabil und dient fuer Kundensuche, Zahlungsabgleich und Channel-Zuordnung.</dd>'
528 . '<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>'
529 . '<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>'
530 . '<dt>Zahlung</dt><dd>Zahlungsanbieter, Zahlungsstatus und Referenz, z.B. PayPal-ID oder externe Channel-Bestellnummer.</dd>'
531 . '<dt>Positionen</dt><dd>Artikelpositionen als Snapshot mit SKU, Titel und Menge.</dd>'
532 . '<dt>Summe</dt><dd>Bruttosumme der Bestellung in der gespeicherten Waehrung.</dd>'
533 . '</dl>'
534 . '<h3>Filter</h3><dl>'
535 . '<dt>Suche</dt><dd>Sucht nach Bestellnummer, Kunde, E-Mail, Channel und Zahlungsreferenz.</dd>'
536 . '<dt>Status</dt><dd>Filtert nach internem Bearbeitungsstatus.</dd>'
537 . '<dt>Zahlung</dt><dd>Filtert nach Zahlungsstatus.</dd>'
538 . '<dt>Versand</dt><dd>Filtert nach operativem Versandstatus, zum Beispiel offen, versendet oder zugestellt.</dd>'
539 . '<dt>Channel</dt><dd>Filtert nach Herkunft, z.B. Shop, eBay, Amazon oder mobile.de.</dd>'
540 . '</dl>'
541 . '<h3>Detail bearbeiten</h3><dl>'
542 . '<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>'
543 . '<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>'
544 . '<dt>Status</dt><dd>Setzt den internen Bearbeitungsstand. Typischer Ablauf: neu -> bezahlt -> in Bearbeitung -> versendet -> abgeschlossen.</dd>'
545 . '<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>'
546 . '<dt>Zahlungsreferenz</dt><dd>Externe Referenz wie PayPal Order ID, eBay Order ID, Amazon Order ID oder interne Buchungsnummer.</dd>'
547 . '<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>'
548 . '<dt>Versandstatus</dt><dd>Operativer Lieferstatus: offen, bereit, versendet, zugestellt oder Retoure.</dd>'
549 . '<dt>Versanddienstleister</dt><dd>Name des Dienstleisters oder der Versandart, z.B. DHL, UPS, Deutsche Post oder Download/Freischaltung.</dd>'
550 . '<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>'
551 . '<dt>Versendet am</dt><dd>Zeitpunkt des Versands. Wird beim Wechsel auf <code>Versendet</code> automatisch gesetzt, wenn noch leer.</dd>'
552 . '<dt>Admin-Notiz</dt><dd>Interne Notizen fuer Rueckfragen, Versandhinweise, Sonderabsprachen oder Channel-Probleme.</dd>'
553 . '<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>'
554 . '<dt>Positionen</dt><dd>Nicht direkt editierbare Snapshot-Positionen. Aenderungen an Artikeln sollen alte Bestellungen nicht veraendern.</dd>'
555 . '<dt>Payload</dt><dd>Rohdaten aus Zahlung oder Channel-Import. Diese helfen bei technischer Pruefung und Supportfaellen.</dd>'
556 . '<dt>Historie</dt><dd>Protokolliert wichtige Status-, Zahlungs-, Versand-, Rechnungs- und Widerrufsereignisse.</dd>'
557 . '<dt>Widerrufe</dt><dd>Zeigt eingegangene Widerrufe, die anhand der Bestellnummer zugeordnet wurden.</dd>'
558 . '</dl>'
559 . '<h3>Loeschen</h3>'
560 . '<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>'
561 . '</section>';
562 }
563
564 private function productsHelpHtml(): string {
565 return '<section class="dbx-shop-help">'
566 . '<h2>Produkte und Artikel-Formular</h2>'
567 . '<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>'
568 . '<h3>Produktliste</h3><dl>'
569 . '<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>'
570 . '<dt>Bearbeiten</dt><dd>Oeffnet das Artikel-Formular im Fenster.</dd>'
571 . '<dt>Ansehen</dt><dd>Oeffnet die Shop-Ansicht des Artikels.</dd>'
572 . '<dt>Loeschen</dt><dd>Verschiebt den Artikel nach Confirm in den Papierkorb.</dd>'
573 . '<dt>Mehrfachaktion</dt><dd>Fuer markierte Artikel koennen Channel hinzugefuegt, entfernt, exportiert oder Produktgruppen gesetzt werden.</dd>'
574 . '</dl>'
575 . '<h3>Grunddaten</h3><dl>'
576 . '<dt>Artikelnummer</dt><dd>Interne und externe Artikelnummer. Sie entspricht praktisch der SKU und sollte eindeutig, stabil und kanalgeeignet sein.</dd>'
577 . '<dt>URL-Name</dt><dd>Technischer URL-Teil fuer die Shop-Detailseite. Verwenden Sie kurze, sprechende Kleinbuchstaben ohne Sonderzeichen.</dd>'
578 . '<dt>Titel</dt><dd>Sichtbarer Produktname im Shop, in Listen und beim Export.</dd>'
579 . '<dt>Kategorie</dt><dd>Freie Kategoriebezeichnung fuer Darstellung und Verwaltung.</dd>'
580 . '<dt>Produkttyp</dt><dd>Unterscheidet digitale Produkte, physische Produkte und Dienstleistungen. Das wirkt auf Versand, Checkout und spaetere Channel-Logik.</dd>'
581 . '<dt>Aktiv</dt><dd>Nur aktive Produkte werden regulaer angeboten.</dd>'
582 . '<dt>Sortierung</dt><dd>Reihenfolge in Listen und Katalogen. Kleine Werte stehen weiter vorne.</dd>'
583 . '</dl>'
584 . '<h3>Beschreibung</h3><dl>'
585 . '<dt>Kurzbeschreibung</dt><dd>Kompakter Text fuer Karten, Listen und Suchergebnisse.</dd>'
586 . '<dt>Beschreibung</dt><dd>Ausfuehrlicher Produkttext fuer Detailseite und Channel-Export.</dd>'
587 . '</dl>'
588 . '<h3>Preis, Versand und Darstellung</h3><dl>'
589 . '<dt>Bruttopreis</dt><dd>Artikelpreis inklusive MwSt. Die MwSt.-Prozentwerte kommen aus den Shop-Einstellungen und der Artikelgruppe.</dd>'
590 . '<dt>Versand-Quelle</dt><dd><code>Aus Gruppe</code> nutzt die Versandgruppe. Individuelle Werte ueberschreiben die Gruppe fuer diesen Artikel.</dd>'
591 . '<dt>Versand brutto individuell</dt><dd>Artikelbezogener Versandwert. <code>-1</code> bedeutet: aus Gruppe oder globalem Fallback berechnen.</dd>'
592 . '<dt>Lagerbestand</dt><dd>Interner Bestand. Fuer digitale Produkte kann 0 trotzdem verkaufbar sein, wenn der Ablauf das erlaubt.</dd>'
593 . '<dt>Badge</dt><dd>Kurzer Hervorhebungs-Text auf Karten, z.B. <code>Neu</code> oder <code>Business</code>.</dd>'
594 . '<dt>Icon</dt><dd>Bootstrap-Icon-Klasse fuer fallbackartige Darstellung ohne Bild.</dd>'
595 . '<dt>Logo-Variante</dt><dd>Optionaler Darstellungswert fuer Templates.</dd>'
596 . '<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>'
597 . '</dl>'
598 . '<h3>Bilder</h3><dl>'
599 . '<dt>Auswahl</dt><dd>Oeffnet den CMS-Medienbrowser fuer Shop-Bilder. Dateien bleiben im Medienbrowser; im Shop wird nur die Zuordnung gespeichert.</dd>'
600 . '<dt>Primaerbild</dt><dd>Markiert das neu zugeordnete Bild als Hauptbild.</dd>'
601 . '<dt>Papierkorb am Bild</dt><dd>Hebt nur die Shop-Zuordnung auf. Die Mediendatei wird nicht geloescht.</dd>'
602 . '</dl>'
603 . '<h3>Channels</h3><dl>'
604 . '<dt>Checkbox</dt><dd>Setzt eine direkte Artikel-Channel-Zuordnung. Diese hat Vorrang vor Channel-Gruppen.</dd>'
605 . '<dt>Status</dt><dd>Zeigt, ob der Channel direkt aktiv, direkt deaktiviert, aus einer Gruppe geerbt oder nicht gesetzt ist.</dd>'
606 . '<dt>Mapping</dt><dd>Oeffnet das produktbezogene Channel-Mapping fuer Kategorie, Pflichtattribute, externe IDs und provider-spezifische Werte.</dd>'
607 . '<dt>Export</dt><dd>Startet den Export fuer diesen Artikel und Channel, wenn der Channel Export erlaubt und ausreichend konfiguriert ist.</dd>'
608 . '</dl>'
609 . '<h3>Bar-Aktionen</h3><dl>'
610 . '<dt>Speichern</dt><dd>Speichert Stammdaten und direkte Channel-Auswahl.</dd>'
611 . '<dt>Artikel ansehen</dt><dd>Oeffnet die Shop-Vorschau.</dd>'
612 . '<dt>Artikelattribute</dt><dd>Oeffnet die Werte der gruppenspezifischen Attribute fuer diesen Artikel.</dd>'
613 . '<dt>Loeschen</dt><dd>Loescht nach Confirm den Artikel.</dd>'
614 . '<dt>Hilfe</dt><dd>Oeffnet diese CMS-Hilfeseite.</dd>'
615 . '<dt>Produktliste</dt><dd>Zurueck zur Produktuebersicht.</dd>'
616 . '</dl>'
617 . '</section>';
618 }
619
620 private function productChannelMappingHelpHtml(): string {
621 return '<section class="dbx-shop-help">'
622 . '<h2>Channel-Mapping je Artikel</h2>'
623 . '<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>'
624 . '<h3>Channel-Werte</h3><dl>'
625 . '<dt>Aktiv</dt><dd>Schaltet die direkte Artikelzuordnung fuer diesen Channel ein. Diese Einstellung hat Vorrang vor Channel-Gruppen.</dd>'
626 . '<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>'
627 . '<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>'
628 . '<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>'
629 . '<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>'
630 . '<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>'
631 . '</dl>'
632 . '<h3>Provider-Mapping</h3><dl>'
633 . '<dt>Shop / Custom Aktion</dt><dd>Interner oder Middleware-Endpunkt, an den der Export uebergeben werden soll.</dd>'
634 . '<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>'
635 . '<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>'
636 . '<dt>eBay Zustand</dt><dd>Condition-Code oder Zustand des Artikels, z.B. neu oder gebraucht.</dd>'
637 . '<dt>eBay Policies</dt><dd>Payment-, Fulfillment- und Return-Policy-IDs. Sie bestimmen Zahlung, Versand und Rueckgabe fuer genau dieses Listing.</dd>'
638 . '<dt>eBay Aspekte</dt><dd>Pflicht- und Zusatzattribute als <code>key=value</code>, z.B. <code>Marke=dbxApp</code>. Ein Wert pro Zeile.</dd>'
639 . '<dt>Amazon Product Type</dt><dd>Amazon Produkttyp fuer die Listings Items API, z.B. <code>PRODUCT</code> oder eine spezifische Kategorie.</dd>'
640 . '<dt>Amazon Requirements</dt><dd>Steuert, welche Anforderungen Amazon beim Listing erwartet, z.B. <code>LISTING</code> oder <code>LISTING_PRODUCT_ONLY</code>.</dd>'
641 . '<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>'
642 . '<dt>mobile.de Fahrzeugdaten</dt><dd>mobile.de ist fuer Fahrzeuge gedacht. Marke, Modell, Erstzulassung, Kilometer, Kraftstoff und Leistung muessen zum Fahrzeug passen.</dd>'
643 . '<dt>Kleinanzeigen</dt><dd>Offiziell nur ueber Partner-/Middleware-Schnittstelle sinnvoll. Kategorie, Ort, Kontakt und Attribute werden an die Middleware gegeben.</dd>'
644 . '</dl>'
645 . '<h3>Exportstatus</h3><dl>'
646 . '<dt>Status</dt><dd>Letzter technischer Exportzustand, z.B. veroeffentlicht, bereit, manuell bereit oder Fehler.</dd>'
647 . '<dt>Meldung</dt><dd>Antwort oder Fehlermeldung des Connectors.</dd>'
648 . '<dt>Payload</dt><dd>Gespeicherte technische Antwortdaten zur Fehlersuche.</dd>'
649 . '</dl>'
650 . '<h3>Bar-Aktionen</h3><dl>'
651 . '<dt>Speichern</dt><dd>Speichert Mapping und Channel-Werte.</dd>'
652 . '<dt>Exportieren</dt><dd>Startet den Export dieses Artikels fuer diesen Channel.</dd>'
653 . '<dt>Hilfe</dt><dd>Oeffnet diese CMS-Hilfeseite.</dd>'
654 . '</dl>'
655 . '</section>';
656 }
657
658 private function productAttributesHelpHtml(): string {
659 return '<section class="dbx-shop-help">'
660 . '<h2>Artikelattribute</h2>'
661 . '<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>'
662 . '<h3>Attribut-Definition</h3><dl>'
663 . '<dt>Gruppe</dt><dd>Artikelgruppe, fuer die das Attribut gilt.</dd>'
664 . '<dt>Key</dt><dd>Technischer Schluessel, z.B. <code>farbe</code>. Er sollte stabil bleiben.</dd>'
665 . '<dt>Titel</dt><dd>Sichtbarer Name des Attributs.</dd>'
666 . '<dt>Typ</dt><dd>Text, Zahl oder Auswahlliste.</dd>'
667 . '<dt>Einheit</dt><dd>Optionale Einheit, z.B. cm, kg, Watt.</dd>'
668 . '<dt>Optionen</dt><dd>Werte fuer Auswahllisten, getrennt durch <code>|</code>.</dd>'
669 . '<dt>Pflicht</dt><dd>Markiert Werte, die fuer vollstaendige Produktdaten erwartet werden.</dd>'
670 . '<dt>Filter</dt><dd>Kann spaeter fuer Katalogfilter verwendet werden.</dd>'
671 . '<dt>Vergleich</dt><dd>Kann spaeter fuer Produktvergleiche verwendet werden.</dd>'
672 . '<dt>Sort / Aktiv</dt><dd>Reihenfolge und Freigabe des Attributs.</dd>'
673 . '</dl>'
674 . '<h3>Artikelwert</h3><dl>'
675 . '<dt>Wert</dt><dd>Konkreter Wert dieses Artikels fuer das Attribut.</dd>'
676 . '<dt>Einheit</dt><dd>Wird aus der Definition angezeigt.</dd>'
677 . '<dt>Eigenschaft</dt><dd>Zeigt Pflicht-/Filter-Hinweise aus der Definition.</dd>'
678 . '</dl>'
679 . '<h3>Aktionen</h3><dl>'
680 . '<dt>Speichern</dt><dd>Speichert die Attributdefinition oder die Artikelwerte.</dd>'
681 . '<dt>Zurueck</dt><dd>Fuehrt zur Produktliste zurueck.</dd>'
682 . '<dt>Hilfe</dt><dd>Oeffnet diese CMS-Hilfeseite.</dd>'
683 . '</dl>'
684 . '</section>';
685 }
686
687 private function shopMediaHelpHtml(): string {
688 return '<section class="dbx-shop-help">'
689 . '<h2>Shop-Medien</h2>'
690 . '<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>'
691 . '<h3>Felder</h3><dl>'
692 . '<dt>Artikel</dt><dd>Optionaler einzelner Artikel, dem neue Bildzuordnungen hinzugefuegt werden.</dd>'
693 . '<dt>Artikelgruppe</dt><dd>Optionale Gruppe. Gruppenbilder koennen bei Artikeln dieser Gruppe als Fallback erscheinen.</dd>'
694 . '<dt>Sortierung</dt><dd>Reihenfolge der Bilder.</dd>'
695 . '<dt>Primaerbild</dt><dd>Markiert die neue Zuordnung als Hauptbild.</dd>'
696 . '</dl>'
697 . '<h3>Aktionen</h3><dl>'
698 . '<dt>Auswahl laden</dt><dd>Laedt die aktuell gewaehlt Artikel-/Gruppenauswahl neu.</dd>'
699 . '<dt>Auswahl</dt><dd>Oeffnet den CMS-Medienbrowser fuer Upload, Auswahl, Zuschneiden, Resize, Batch und Wartung.</dd>'
700 . '<dt>Zugeordnete Shop-Bilder</dt><dd>Zeigt vorhandene Artikel- und Gruppenbild-Zuordnungen.</dd>'
701 . '<dt>Hilfe</dt><dd>Oeffnet diese CMS-Hilfeseite.</dd>'
702 . '</dl>'
703 . '</section>';
704 }
705
706 private function loadShopHelpContentCacheSupport(): void {
707 $bootstrap = dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap.php';
708 if (is_file($bootstrap)) {
709 require_once $bootstrap;
710 }
711 $cacheFile = dirname(__DIR__, 2) . '/dbxContent/include/dbxContentPageCache.class.php';
712 if (is_file($cacheFile)) {
713 require_once $cacheFile;
714 }
715 $indexFile = dirname(__DIR__, 2) . '/dbxContent/include/dbxContentPermalinkIndex.class.php';
716 if (is_file($indexFile)) {
717 require_once $indexFile;
718 }
719 }
720
721 private function invalidateShopHelpCache(int $cid): void {
722 if ($cid <= 0) {
723 return;
724 }
725 $this->loadShopHelpContentCacheSupport();
726 if (class_exists('\\dbx\\dbxContent\\dbxContentPageCache')) {
728 }
729 }
730
731 private function syncShopHelpPermalinkIndex(int $cid, string $permalink, string $rights = 'admin'): void {
732 if ($cid <= 0 || $permalink === '') {
733 return;
734 }
735 $this->loadShopHelpContentCacheSupport();
736 if (class_exists('\\dbx\\dbxContent\\dbxContentPermalinkIndex')) {
737 \dbx\dbxContent\dbxContentPermalinkIndex::upsertPage($cid, $permalink, $rights, 1);
738 }
739 }
740
741 private function removeShopHelpPermalinkIndex(string $permalink): void {
742 $permalink = trim($permalink);
743 if ($permalink === '') {
744 return;
745 }
746 $this->loadShopHelpContentCacheSupport();
747 if (class_exists('\\dbx\\dbxContent\\dbxContentPermalinkIndex')) {
749 }
750 }
751
752 private function ensureShopChannelHelpPage(): int {
753 return $this->ensureShopAdminHelpPage('shop/help-channel-groups', 'Hilfe: Channel-Gruppen', 'Hilfe zur Pflege von Shop Channel-Gruppen.', 'shop,channel,channel-gruppen,hilfe', $this->channelGroupsHelpHtml(), '9010', array('shop/channel'));
754 }
755
756 private function ensureShopChannelsHelpPage(): int {
757 return $this->ensureShopAdminHelpPage('shop/help-channels', 'Hilfe: Channels', 'Hilfe zur Pflege von Shop Channels und Marktplatz-Anbindungen.', 'shop,channels,api,marktplatz,hilfe', $this->channelsHelpHtml(), '9015', array('shop/channels'));
758 }
759
760 private function ensureShopChannelProviderHelpPage(string $platform): int {
761 $allowed = array('shop', 'amazon', 'ebay', 'kleinanzeigen', 'mobile', 'custom');
762 if (!in_array($platform, $allowed, true)) {
763 $platform = 'custom';
764 }
765 $titles = array(
766 'shop' => 'Hilfe: Channel Shop',
767 'amazon' => 'Hilfe: Channel Amazon',
768 'ebay' => 'Hilfe: Channel eBay',
769 'kleinanzeigen' => 'Hilfe: Channel Kleinanzeigen',
770 'mobile' => 'Hilfe: Channel mobile.de',
771 'custom' => 'Hilfe: Eigener Channel',
772 );
773 $sort = array('shop' => '9016', 'amazon' => '9017', 'ebay' => '9018', 'kleinanzeigen' => '9019', 'mobile' => '9024', 'custom' => '9025');
774 return $this->ensureShopAdminHelpPage(
775 'shop/help-channel-' . $platform,
776 $titles[$platform],
777 'Hilfe zu API-Daten und Rueckmeldungen fuer ' . $titles[$platform] . '.',
778 'shop,channels,' . $platform . ',api,hilfe',
779 $this->channelProviderHelpHtml($platform),
780 $sort[$platform],
781 array('shop/channels/' . $platform)
782 );
783 }
784
785 private function ensureShopProductGroupsHelpPage(): int {
786 return $this->ensureShopAdminHelpPage('shop/help-groups', 'Hilfe: Artikelgruppen', 'Hilfe zur Pflege von Shop Artikelgruppen.', 'shop,artikelgruppen,produktgruppen,hilfe', $this->productGroupsHelpHtml(), '9020', array('shop/groups'));
787 }
788
789 private function ensureShopShippingGroupsHelpPage(): int {
790 return $this->ensureShopAdminHelpPage('shop/help-shipping-groups', 'Hilfe: Versandgruppen', 'Hilfe zur Pflege von Shop Versandgruppen.', 'shop,versandgruppen,shipping,hilfe', $this->shippingGroupsHelpHtml(), '9030', array('shop/shipping-groups'));
791 }
792
793 private function ensureShopSettingsHelpPage(): int {
794 return $this->ensureShopAdminHelpPage('shop/help-settings', 'Hilfe: Shop-Einstellungen', 'Hilfe zur Pflege der globalen Shop-Einstellungen.', 'shop,einstellungen,mwst,zahlung,versand,hilfe', $this->settingsHelpHtml(), '9040', array('shop/settings'));
795 }
796
797 private function ensureShopOrdersHelpPage(): int {
798 return $this->ensureShopAdminHelpPage('shop/help-orders', 'Hilfe: Bestellungen', 'Hilfe zur Bearbeitung von Shop- und Channel-Bestellungen.', 'shop,bestellungen,orders,zahlung,channel,hilfe', $this->ordersHelpHtml(), '9050', array('shop/orders'));
799 }
800
801 private function ensureShopProductsHelpPage(): int {
802 return $this->ensureShopAdminHelpPage('shop/help-products', 'Hilfe: Produkte', 'Hilfe zur Produktliste und zum Artikel-Formular.', 'shop,produkte,artikel,formular,hilfe', $this->productsHelpHtml(), '9005', array('shop/products'));
803 }
804
805 private function ensureShopProductChannelMappingHelpPage(): int {
806 return $this->ensureShopAdminHelpPage('shop/help-product-channel-mapping', 'Hilfe: Channel-Mapping je Artikel', 'Hilfe zur produktbezogenen Channel-Mapping-Maske.', 'shop,produkte,channels,mapping,hilfe', $this->productChannelMappingHelpHtml(), '9014', array('shop/product-channel-mapping'));
807 }
808
809 private function ensureShopProductAttributesHelpPage(): int {
810 return $this->ensureShopAdminHelpPage('shop/help-product-attributes', 'Hilfe: Artikelattribute', 'Hilfe zu Attributdefinitionen und Artikelwerten.', 'shop,produkte,attribute,hilfe', $this->productAttributesHelpHtml(), '9028', array('shop/product-attributes'));
811 }
812
813 private function ensureShopMediaHelpPage(): int {
814 return $this->ensureShopAdminHelpPage('shop/help-media', 'Hilfe: Shop-Medien', 'Hilfe zur Medienzuordnung fuer Shop-Artikel und Artikelgruppen.', 'shop,medien,bilder,hilfe', $this->shopMediaHelpHtml(), '9035', array('shop/media-help'));
815 }
816
817 private function ensureShopAdminHelpPage(string $permalink, string $title, string $description, string $keywords, string $content, string $sorter, array $oldPermalinks = array()): int {
818 $db = $this->db();
819 $contentDd = $this->contentDd();
820 try {
821 $permalinks = array_values(array_unique(array_merge(array($permalink), $oldPermalinks)));
822 foreach ($permalinks as $candidatePermalink) {
823 $candidatePermalink = trim((string)$candidatePermalink);
824 if ($candidatePermalink === '') {
825 continue;
826 }
827 $row = $db->select1($contentDd, array('permalink' => $candidatePermalink), 'id,content,permalink,title,description,keywords,sorter', 0);
828 if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
829 $id = (int)$row['id'];
830 $needsUpdate = trim((string)($row['content'] ?? '')) !== trim($content)
831 || trim((string)($row['permalink'] ?? '')) !== $permalink
832 || trim((string)($row['title'] ?? '')) !== $title
833 || trim((string)($row['description'] ?? '')) !== $description
834 || trim((string)($row['keywords'] ?? '')) !== $keywords
835 || trim((string)($row['sorter'] ?? '')) !== $sorter
836 || $candidatePermalink !== $permalink;
837 if ($needsUpdate) {
838 $this->invalidateShopHelpCache($id);
839 $db->update($contentDd, array(
840 'permalink' => $permalink,
841 'title' => $title,
842 'description' => $description,
843 'keywords' => $keywords,
844 'sorter' => $sorter,
845 'content' => $content,
846 'group_read' => 'admin',
847 'activ' => 1,
848 'addmenu' => 0,
849 'meta_robots' => 'noindex,nofollow',
850 ), $id);
851 }
852 foreach ($oldPermalinks as $oldPermalink) {
853 $this->removeShopHelpPermalinkIndex((string)$oldPermalink);
854 }
855 $this->syncShopHelpPermalinkIndex($id, $permalink, 'admin');
856 return $id;
857 }
858 }
859
860 $folderId = $this->ensureShopHelpFolder();
861 $insert = array(
862 'activ' => 1,
863 'addmenu' => 0,
864 'folder' => $folderId,
865 'group_read' => 'admin',
866 'sorter' => $sorter,
867 'title' => $title,
868 'permalink' => $permalink,
869 'description' => $description,
870 'keywords' => $keywords,
871 'template' => 'c-body1-footer',
872 'content' => $content,
873 'meta_robots' => 'noindex,nofollow',
874 );
875 $ok = (int)$db->insert($contentDd, $insert);
876 if ($ok === 1) {
877 $id = (int)$db->get_insert_id();
878 $this->invalidateShopHelpCache($id);
879 $this->syncShopHelpPermalinkIndex($id, $permalink, 'admin');
880 return $id;
881 }
882 } catch (\Throwable $e) {
883 if (function_exists('dbx')) {
884 dbx()->debug('dbxShop help page failed', $e->getMessage());
885 }
886 }
887
888 return 0;
889 }
890
891 private function ensureShopMediaUsagePage(): int {
892 $db = $this->db();
893 $contentDd = $this->contentDd();
894 try {
895 $row = $db->select1($contentDd, array('permalink' => 'outside/shop-media-usage'), 'id', 0);
896 if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
897 return (int)$row['id'];
898 }
899
900 $folderId = 0;
901 $folder = $db->select1($this->folderDd(), array('name' => 'outside'), 'id', 0);
902 if (is_array($folder)) {
903 $folderId = (int)($folder['id'] ?? 0);
904 }
905
906 $insert = array(
907 'activ' => 0,
908 'addmenu' => 0,
909 'folder' => $folderId,
910 'group_read' => 'admin',
911 'sorter' => '9999',
912 'title' => 'Shop Medienverwendung',
913 'permalink' => 'outside/shop-media-usage',
914 'description' => 'Interne Seite fuer Shop-Medienverwendung.',
915 'keywords' => '',
916 'template' => 'c-body1-footer',
917 'content' => '<p>Interne Seite fuer Shop-Medienverwendung.</p>',
918 'meta_robots' => 'noindex,nofollow',
919 );
920 $ok = (int)$db->insert($contentDd, $insert);
921 if ($ok === 1) {
922 $id = (int)$db->get_insert_id();
923 return $id;
924 }
925 } catch (\Throwable $e) {
926 if (function_exists('dbx')) {
927 dbx()->debug('dbxShop media_usage page failed', $e->getMessage());
928 }
929 }
930
931 return 0;
932 }
933
934 private function shopMediaUsageSlot(): string {
935 $slot = strtolower(trim((string)dbx()->get_config('dbxShop', 'media_usage_slot')));
936 $allowed = array('shop','hero','gallery','inline','header','teaser','footer');
937 return in_array($slot, $allowed, true) ? $slot : 'shop';
938 }
939
940 private function shopMediaUsageSorter($db, int $contentId, string $slot): string {
941 $where = 'content_id = ' . $contentId . " AND slot = '" . str_replace("'", "''", $slot) . "' AND active = 1";
942 $rows = $db->select('dbxMediaUsage', $where, 'sorter,id', 'sorter,id', 'DESC', '', 1, 0, 0);
943 $max = 0;
944 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
945 $max = (int)($rows[0]['sorter'] ?? 0);
946 }
947 return sprintf('%04d', $max + 10);
948 }
949
950 private function shopMediaFolderPath(): string {
951 $base = function_exists('dbx_get_file_dir') ? (string)dbx_get_file_dir() : 'files';
952 return rtrim($base, '/\\') . '/media/img/shop';
953 }
954
955 private function normalizeShopSourceImagePath(string $path): string {
956 $path = trim(str_replace('\\', '/', $path));
957 $path = preg_replace('~^https?://[^/]+/~i', '', $path) ?: $path;
958 $path = preg_replace('~^/?dbxapp/~i', '', $path) ?: $path;
959 return ltrim($path, '/');
960 }
961
962 private function filePathForShopImage(string $path): string {
963 $path = $this->normalizeShopSourceImagePath($path);
964 if ($path === '' || strpos($path, '..') !== false || preg_match('~(^|/)\.~', $path)) {
965 return '';
966 }
967 if (!function_exists('dbx_get_file_dir')) {
968 return '';
969 }
970
971 if (preg_match('~^files/(.+)$~i', $path, $m)) {
972 return rtrim((string)dbx_get_file_dir(), '/\\') . '/' . $m[1];
973 }
974 if (preg_match('~^(media|shop)/~i', $path)) {
975 return rtrim((string)dbx_get_file_dir(), '/\\') . '/' . $path;
976 }
977 return '';
978 }
979
980 private function mediaMime(string $file): string {
981 $mime = function_exists('mime_content_type') ? (string)@mime_content_type($file) : '';
982 if ($mime !== '') {
983 return $mime;
984 }
985
986 $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
987 $map = array(
988 'jpg' => 'image/jpeg',
989 'jpeg' => 'image/jpeg',
990 'png' => 'image/png',
991 'gif' => 'image/gif',
992 'webp' => 'image/webp',
993 'svg' => 'image/svg+xml',
994 );
995 return $map[$ext] ?? 'application/octet-stream';
996 }
997
998 private function ensureMediaRecordForShopImage(array $image): int {
999 $mediaId = (int)($image['media_id'] ?? 0);
1000 if ($mediaId > 0) {
1001 return $mediaId;
1002 }
1003
1004 $sourcePath = $this->normalizeShopSourceImagePath((string)($image['image_path'] ?? ''));
1005 if ($sourcePath === '' || stripos($sourcePath, 'dbxmedia:') === 0) {
1006 return 0;
1007 }
1008
1009 $sourceFile = $this->filePathForShopImage($sourcePath);
1010 if ($sourceFile === '' || !is_file($sourceFile) || !is_readable($sourceFile)) {
1011 return 0;
1012 }
1013
1014 $name = basename($sourceFile);
1015 $name = preg_replace('/[^A-Za-z0-9_.-]+/', '-', $name) ?: ('shop-image-' . (int)($image['id'] ?? 0));
1016 $targetDir = $this->shopMediaFolderPath();
1017 if (!is_dir($targetDir)) {
1018 @mkdir($targetDir, 0775, true);
1019 }
1020 $targetFile = rtrim($targetDir, '/\\') . '/' . $name;
1021 if (!is_file($targetFile)) {
1022 @copy($sourceFile, $targetFile);
1023 }
1024 if (!is_file($targetFile)) {
1025 return 0;
1026 }
1027
1028 $rel = 'media/img/shop/' . $name;
1029 $db = $this->db();
1030 $existing = $db->select1('dbxMedia', array('file_path' => $rel), 'id,active', 0);
1031 if (is_array($existing) && (int)($existing['id'] ?? 0) > 0) {
1032 $mediaId = (int)$existing['id'];
1033 if ((int)($existing['active'] ?? 0) !== 1) {
1034 $db->update('dbxMedia', array('active' => 1), $mediaId);
1035 }
1036 $this->repo()->updateImageMediaReference((int)($image['id'] ?? 0), $mediaId, 'dbxmedia:' . $mediaId);
1037 return $mediaId;
1038 }
1039
1040 $width = 0;
1041 $height = 0;
1042 $size = @getimagesize($targetFile);
1043 if (is_array($size)) {
1044 $width = (int)($size[0] ?? 0);
1045 $height = (int)($size[1] ?? 0);
1046 }
1047 $title = trim((string)($image['title'] ?? ''));
1048 if ($title === '') {
1049 $title = pathinfo($name, PATHINFO_FILENAME);
1050 }
1051 $mime = $this->mediaMime($targetFile);
1052 $insert = array(
1053 'active' => 1,
1054 'content_id' => 0,
1055 'folder_id' => 0,
1056 'slot' => 'shop',
1057 'usage' => 'shop',
1058 'sorter' => '',
1059 'template' => '',
1060 'title' => $title,
1061 'alt' => (string)($image['alt'] ?? $title),
1062 'caption' => '',
1063 'file_name' => $name,
1064 'file_path' => $rel,
1065 'mime' => $mime,
1066 'size' => (int)@filesize($targetFile),
1067 'width' => $width,
1068 'height' => $height,
1069 'tags' => 'shop',
1070 'media_type' => 'image',
1071 'storage_type' => 'local',
1072 'media_folder' => 'img/shop',
1073 );
1074 $ok = (int)$db->insert('dbxMedia', $insert);
1075 if ($ok !== 1) {
1076 return 0;
1077 }
1078 $mediaId = (int)$db->get_insert_id();
1079 if ($mediaId <= 0) {
1080 return 0;
1081 }
1082 if ($mediaId > 0) {
1083 $this->repo()->updateImageMediaReference((int)($image['id'] ?? 0), $mediaId, 'dbxmedia:' . $mediaId);
1084 }
1085 return $mediaId;
1086 }
1087
1088 private function migrateExistingShopImagesToMedia(): void {
1089 foreach ($this->repo()->allImages() as $image) {
1090 if ((int)($image['active'] ?? 0) !== 1) {
1091 continue;
1092 }
1093 $this->ensureMediaRecordForShopImage($image);
1094 }
1095 }
1096
1097 private function syncShopMediaUsage(): void {
1098 $db = $this->db();
1099 $contentId = $this->shopMediaUsageContentId();
1100 if ($contentId <= 0) {
1101 return;
1102 }
1103
1104 $slot = $this->shopMediaUsageSlot();
1105 $sourceNeedle = '%"source":"dbxShop"%';
1106 try {
1107 $this->migrateExistingShopImagesToMedia();
1108
1109 $db->update(
1110 'dbxMediaUsage',
1111 array('active' => 0),
1112 "content_id = " . $contentId . " AND settings LIKE '" . str_replace("'", "''", $sourceNeedle) . "' AND active = 1",
1113 0,
1114 1,
1115 1,
1116 0
1117 );
1118
1119 $byMedia = array();
1120 foreach ($this->repo()->allImages() as $image) {
1121 if ((int)($image['active'] ?? 0) !== 1) {
1122 continue;
1123 }
1124 $mediaId = (int)($image['media_id'] ?? 0);
1125 if ($mediaId <= 0) {
1126 continue;
1127 }
1128 if (!isset($byMedia[$mediaId])) {
1129 $byMedia[$mediaId] = array(
1130 'media_id' => $mediaId,
1131 'title' => (string)($image['title'] ?? ''),
1132 'product_ids' => array(),
1133 'group_ids' => array(),
1134 );
1135 }
1136 $productId = (int)($image['product_id'] ?? 0);
1137 $groupId = (int)($image['group_id'] ?? 0);
1138 if ($productId > 0) {
1139 $byMedia[$mediaId]['product_ids'][$productId] = $productId;
1140 }
1141 if ($groupId > 0) {
1142 $byMedia[$mediaId]['group_ids'][$groupId] = $groupId;
1143 }
1144 }
1145
1146 foreach ($byMedia as $mediaId => $info) {
1147 $settings = json_encode(array(
1148 'source' => 'dbxShop',
1149 'product_ids' => array_values($info['product_ids']),
1150 'group_ids' => array_values($info['group_ids']),
1151 ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
1152 $db->insert('dbxMediaUsage', array(
1153 'active' => 1,
1154 'media_id' => (int)$mediaId,
1155 'content_id' => $contentId,
1156 'folder_id' => 0,
1157 'slot' => $slot,
1158 'sorter' => $this->shopMediaUsageSorter($db, $contentId, $slot),
1159 'template' => 'image-gallery',
1160 'caption' => (string)($info['title'] ?? ''),
1161 'settings' => $settings ?: '{"source":"dbxShop"}',
1162 ));
1163 }
1164 } catch (\Throwable $e) {
1165 if (function_exists('dbx')) {
1166 dbx()->debug('dbxShop media_usage sync failed', $e->getMessage());
1167 }
1168 }
1169 }
1170
1171 private function chips(array $items, string $key = 'title', string $class = ''): string {
1172 $html = '';
1173 foreach ($items as $item) {
1174 $value = trim((string)($item[$key] ?? ''));
1175 if ($value === '') continue;
1176 $html .= '<span class="badge text-bg-light border">' . $this->h($value) . '</span>';
1177 }
1178 if ($html === '') {
1179 return '<span class="text-muted small">keine Werte</span>';
1180 }
1181 $class = trim('dbx-shop-report-chip-grid ' . $class);
1182 return '<div class="' . $this->h($class) . '">' . $html . '</div>';
1183 }
1184
1185 private function attributeBadges(array $product): string {
1186 $html = '';
1187 foreach (($product['attributes'] ?? array()) as $attribute) {
1188 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
1189 if ($value === '') continue;
1190 $html .= '<span class="badge text-bg-info text-start text-wrap">' . $this->h($attribute['title'] ?? '') . ': ' . $this->h($value) . '</span>';
1191 }
1192 return $html !== '' ? '<div class="dbx-shop-report-chip-grid dbx-shop-report-chip-grid-attributes">' . $html . '</div>' : '<span class="text-muted small">keine Werte</span>';
1193 }
1194
1195 private function attributeOptions(string $options): array {
1196 $items = preg_split('~[|;\r\n]+~', $options) ?: array();
1197 return array_values(array_filter(array_map('trim', $items), fn($item) => $item !== ''));
1198 }
1199
1200 private function optionsHtml(array $options, string $selected): string {
1201 $html = '';
1202 foreach ($options as $value => $label) {
1203 $value = (string)$value;
1204 $html .= '<option value="' . $this->h($value) . '"' . ($value === $selected ? ' selected' : '') . '>' . $this->h($label) . '</option>';
1205 }
1206 return $html;
1207 }
1208
1209 private function shopConfig(): array {
1210 $cfg = dbx()->get_config('dbxShop');
1211 return is_array($cfg) ? $cfg : array();
1212 }
1213
1214 private function channelsEnabled(): bool {
1215 $value = strtolower(trim((string)dbx()->get_config('dbxShop', 'channels_enabled')));
1216 return !in_array($value, array('0', 'false', 'off', 'no', 'nein'), true);
1217 }
1218
1219 private function taxRatesConfig(): array {
1220 $cfg = $this->shopConfig();
1221 $rates = $cfg['tax_rates'] ?? array();
1222 if (!is_array($rates) || !count($rates)) {
1223 $rates = array(
1224 'mwst1' => array('title' => 'MwSt. normal', 'rate' => '19'),
1225 'mwst2' => array('title' => 'MwSt. ermaessigt', 'rate' => '7'),
1226 'mwst3' => array('title' => 'MwSt. vorbereitet', 'rate' => '22'),
1227 );
1228 }
1229 foreach (array('mwst1' => 'MwSt. normal', 'mwst2' => 'MwSt. ermaessigt', 'mwst3' => 'MwSt. vorbereitet') as $key => $title) {
1230 if (!isset($rates[$key]) || !is_array($rates[$key])) {
1231 $rates[$key] = array('title' => $title, 'rate' => $key === 'mwst2' ? '7' : ($key === 'mwst3' ? '22' : '19'));
1232 }
1233 }
1234 return $rates;
1235 }
1236
1237 private function taxClassOptions(string $selected): string {
1238 $options = array();
1239 foreach ($this->taxRatesConfig() as $key => $rate) {
1240 if (!is_array($rate)) continue;
1241 $label = trim((string)($rate['title'] ?? $key));
1242 $value = number_format((float)($rate['rate'] ?? 0), 2, ',', '.');
1243 $options[$key] = $label . ' (' . $value . '%)';
1244 }
1245 return $this->optionsHtml($options, $selected !== '' ? $selected : (string)($this->shopConfig()['default_tax_class'] ?? 'mwst1'));
1246 }
1247
1248 private function normalizedText(string $value): string {
1249 $value = strtolower($value);
1250 $value = strtr($value, array('ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'));
1251 $value = preg_replace('~[^a-z0-9]+~', ' ', $value) ?: '';
1252 return preg_replace('~\\s+~', ' ', trim($value)) ?: '';
1253 }
1254
1255 private function searchTerms(string $query): array {
1256 $terms = preg_split('~\\s+~', $this->normalizedText($query)) ?: array();
1257 $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'));
1258 $out = array();
1259 foreach ($terms as $term) {
1260 $term = trim($term);
1261 if ($term === '' || isset($stopWords[$term])) {
1262 continue;
1263 }
1264 if (strlen($term) < 2 && !ctype_digit($term)) {
1265 continue;
1266 }
1267 $out[$term] = true;
1268 }
1269 return array_keys($out);
1270 }
1271
1272 private function productAttributeText(array $product): string {
1273 $parts = array();
1274 foreach (($product['attributes'] ?? array()) as $attribute) {
1275 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
1276 $parts[] = (string)($attribute['title'] ?? '');
1277 $parts[] = (string)($attribute['attr_key'] ?? '');
1278 if ($value !== '') {
1279 $parts[] = $value;
1280 }
1281 }
1282 return implode(' ', $parts);
1283 }
1284
1285 private function productGroupText(array $product): string {
1286 $parts = array();
1287 foreach (($product['groups'] ?? array()) as $group) {
1288 $parts[] = (string)($group['title'] ?? '');
1289 $parts[] = (string)($group['group_key'] ?? '');
1290 $parts[] = (string)($group['description'] ?? '');
1291 $parts[] = (string)($group['attribute_notes'] ?? '');
1292 }
1293 foreach (($product['shipping_groups'] ?? array()) as $group) {
1294 $parts[] = (string)($group['title'] ?? '');
1295 $parts[] = (string)($group['group_key'] ?? '');
1296 }
1297 foreach (($product['channel_groups'] ?? array()) as $group) {
1298 $parts[] = (string)($group['title'] ?? '');
1299 $parts[] = (string)($group['group_key'] ?? '');
1300 }
1301 foreach (($product['channels'] ?? array()) as $channel) {
1302 $parts[] = (string)($channel['title'] ?? '');
1303 $parts[] = (string)($channel['channel_key'] ?? '');
1304 }
1305 return implode(' ', $parts);
1306 }
1307
1308 private function searchFieldScore(string $text, string $term, int $weight): int {
1309 if ($text === '' || $term === '') {
1310 return 0;
1311 }
1312 if ($text === $term) {
1313 return $weight * 8;
1314 }
1315 $termLength = strlen($term);
1316 $compactText = str_replace(' ', '', $text);
1317 $compactTerm = str_replace(' ', '', $term);
1318 if (strpos($text, $term) !== false || strpos($compactText, $compactTerm) !== false) {
1319 return $weight * 5;
1320 }
1321 $best = 0;
1322 foreach (preg_split('~\\s+~', $text) ?: array() as $token) {
1323 $token = trim($token);
1324 if ($token === '') {
1325 continue;
1326 }
1327 if ($token === $term) {
1328 $best = max($best, $weight * 6);
1329 continue;
1330 }
1331 if ($termLength < 3) {
1332 continue;
1333 }
1334 if (strlen($token) >= $termLength && strpos($token, $term) === 0) {
1335 $best = max($best, $weight * 4);
1336 continue;
1337 }
1338 if (
1339 $termLength >= 4
1340 && strlen($token) >= 4
1341 && substr($token, 0, 3) === substr($term, 0, 3)
1342 && abs(strlen($token) - $termLength) <= ($termLength >= 7 ? 2 : 1)
1343 && levenshtein($token, $term) <= ($termLength >= 7 ? 2 : 1)
1344 ) {
1345 $best = max($best, $weight * 2);
1346 }
1347 }
1348 return $best;
1349 }
1350
1351 private function productSearchScore(array $product, string $query): int {
1352 $terms = $this->searchTerms($query);
1353 if ($terms === array()) {
1354 return 1;
1355 }
1356
1357 $primary = $this->normalizedText(implode(' ', array(
1358 (string)($product['sku'] ?? ''),
1359 (string)($product['title'] ?? ''),
1360 (string)($product['category'] ?? ''),
1361 (string)($product['badge'] ?? ''),
1362 (string)($product['product_type'] ?? ''),
1363 )));
1364 $secondary = $this->normalizedText(implode(' ', array(
1365 (string)($product['summary'] ?? ''),
1366 (string)($product['description'] ?? ''),
1367 )));
1368 $attributes = $this->normalizedText($this->productAttributeText($product));
1369 $groups = $this->normalizedText($this->productGroupText($product));
1370
1371 $score = 0;
1372 $matched = 0;
1373 $firstTermPrimaryScore = 0;
1374 $termCount = count($terms);
1375
1376 foreach ($terms as $idx => $term) {
1377 $primaryScore = $this->searchFieldScore($primary, $term, 10);
1378 $termScore = max(
1379 $primaryScore,
1380 $this->searchFieldScore($attributes, $term, 7),
1381 $this->searchFieldScore($secondary, $term, 4),
1382 $this->searchFieldScore($groups, $term, 3)
1383 );
1384
1385 if ($idx === 0) {
1386 $firstTermPrimaryScore = $primaryScore;
1387 }
1388 if ($termScore > 0) {
1389 $matched++;
1390 $score += $termScore;
1391 }
1392 }
1393
1394 if ($matched === 0) {
1395 return 0;
1396 }
1397 if ($termCount === 1) {
1398 return $score;
1399 }
1400 if ($matched === $termCount || $firstTermPrimaryScore > 0 || $score >= 20) {
1401 return $score + ($matched * 3);
1402 }
1403 return 0;
1404 }
1405
1406 private function productSortValue(array $product, string $sort) {
1407 switch ($sort) {
1408 case 'sku':
1409 case 'title':
1410 return $this->normalizedText((string)($product[$sort] ?? ''));
1411 case 'price_gross':
1412 case 'effective_tax_rate':
1413 case 'effective_shipping_gross':
1414 return (float)($product[$sort] ?? 0);
1415 case 'active':
1416 return (int)($product['active'] ?? 0);
1417 case 'sorter':
1418 default:
1419 return (int)($product['sorter'] ?? 100);
1420 }
1421 }
1422
1423 private function sortProductsForReport(array $products, string $query, string $sort, string $direction): array {
1424 $hasQuery = $this->searchTerms($query) !== array();
1425 $direction = strtoupper($direction) === 'DESC' ? 'DESC' : 'ASC';
1426 usort($products, function(array $a, array $b) use ($hasQuery, $sort, $direction): int {
1427 if ($hasQuery && (int)($a['_search_score'] ?? 0) !== (int)($b['_search_score'] ?? 0)) {
1428 return (int)($b['_search_score'] ?? 0) <=> (int)($a['_search_score'] ?? 0);
1429 }
1430
1431 $av = $this->productSortValue($a, $sort);
1432 $bv = $this->productSortValue($b, $sort);
1433 if ($av == $bv) {
1434 return strcasecmp((string)($a['title'] ?? ''), (string)($b['title'] ?? ''));
1435 }
1436 $cmp = is_numeric($av) && is_numeric($bv) ? ($av <=> $bv) : strcasecmp((string)$av, (string)$bv);
1437 return $direction === 'DESC' ? -$cmp : $cmp;
1438 });
1439 return $products;
1440 }
1441
1442 public function product_report_next_record($report, $record) {
1443 if (!is_array($record)) {
1444 return $record;
1445 }
1446
1447 $sku = (string)($record['sku'] ?? '');
1448 $productId = (int)($record['id'] ?? 0);
1449 $productTitle = trim((string)($record['title'] ?? $sku));
1450 $summary = trim((string)($record['summary'] ?? ''));
1451 $image = $record['images'][0] ?? array();
1452 $imgUrl = is_array($image) ? $this->mediaItemUrl($image, true) : '';
1453
1454 $record['image_view'] = $imgUrl !== ''
1455 ? '<span class="dbx-shop-report-image"><img src="' . $this->h($imgUrl) . '" alt="" loading="lazy"></span>'
1456 : '<span class="text-muted small">kein Bild</span>';
1457 $record['article_view'] = '<div class="dbx-shop-report-article-scroll"><code class="dbx-shop-report-sku">' . $this->h($sku) . '</code>'
1458 . '<br><strong>' . $this->h($productTitle) . '</strong>'
1459 . ($summary !== '' ? '<br><small class="text-muted">' . $this->h($summary) . '</small>' : '')
1460 . '</div>';
1461 $record['groups_view'] = $this->chips($record['groups'] ?? array());
1462 $record['attributes_view'] = $this->attributeBadges($record);
1463 $record['shipping_groups_view'] = $this->chips($record['shipping_groups'] ?? array());
1464 $record['channel_groups_view'] = $this->chips($record['channel_groups'] ?? array());
1465 $record['channels_view'] = $this->chips($record['channels'] ?? array(), 'title', 'dbx-shop-report-chip-grid-channels');
1466 $record['price_view'] = '<span class="text-nowrap">' . $this->money($record['price_gross'] ?? 0) . '</span>';
1467 $record['tax_view'] = number_format((float)($record['effective_tax_rate'] ?? 0), 2, ',', '.') . '%';
1468 $record['shipping_view'] = '<span class="text-nowrap">' . $this->money($record['effective_shipping_gross'] ?? 0) . '</span>';
1469 $record['status_view'] = ((int)($record['active'] ?? 0) === 1)
1470 ? '<span class="badge text-bg-success">Aktiv</span>'
1471 : '<span class="badge text-bg-secondary">Inaktiv</span>';
1472
1473 return $record;
1474 }
1475
1476 public function product_report_row_action_data($report, $data) {
1477 if (!is_array($data) || !isset($data['data']) || !is_array($data['data'])) {
1478 return $data;
1479 }
1480
1481 $type = (string)($data['type'] ?? '');
1482 $record = is_array($data['record'] ?? null) ? $data['record'] : array();
1483 $rid = (int)($data['data']['rid'] ?? $record['id'] ?? 0);
1484 $sku = (string)($record['sku'] ?? '');
1485 $title = trim((string)($record['title'] ?? $sku));
1486
1487 if ($type === 'edit') {
1488 $url = '?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $rid . '&dbx_ajax=1';
1489 $data['data']['action'] = $url;
1490 $data['data']['class'] = 'openWin dbx-win';
1491 $data['data']['tooltip'] = 'Artikel bearbeiten: ' . $title;
1492 } elseif ($type === 'show') {
1493 $url = '?dbx_modul=dbxShop&dbx_run1=product&sku=' . rawurlencode($sku) . '&dbx_ajax=1';
1494 $data['data']['action'] = $url;
1495 $data['data']['class'] = 'openWin dbx-win';
1496 $data['data']['tooltip'] = 'Artikel ansehen: ' . $title;
1497 } elseif ($type === 'delete') {
1498 $data['data']['action'] = '?dbx_modul=dbxShop_admin&dbx_run1=products';
1499 $data['data']['confirm'] = 'Artikel "' . $this->h($title) . '" wirklich loeschen?';
1500 }
1501
1502 return $data;
1503 }
1504
1505 private function productTreePanel(array $products): string {
1506 $groups = $this->repo()->groups();
1507 $groupsByParent = array();
1508 foreach ($groups as $group) {
1509 $parentId = (int)($group['parent_id'] ?? 0);
1510 $groupsByParent[$parentId][] = $group;
1511 }
1512
1513 $productsByGroup = array();
1514 foreach ($products as $product) {
1515 $groupId = (int)($product['product_group_id'] ?? 0);
1516 if ($groupId <= 0 && isset($product['groups'][0])) {
1517 $groupId = (int)($product['groups'][0]['id'] ?? 0);
1518 }
1519 $productsByGroup[$groupId][] = $product;
1520 }
1521
1522 $renderProducts = function(int $groupId, bool $asListItem = false) use (&$productsByGroup): string {
1523 $items = '';
1524 foreach (($productsByGroup[$groupId] ?? array()) as $product) {
1525 $id = (int)($product['id'] ?? 0);
1526 $title = trim((string)($product['title'] ?? 'Artikel'));
1527 $sku = trim((string)($product['sku'] ?? ''));
1528 if ($id <= 0) continue;
1529 $searchText = trim($title . ' ' . $sku);
1530 $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) . '">';
1531 $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>';
1532 $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 . '&amp;dbx_ajax=1" title="Artikel bearbeiten"><i class="bi bi-pencil"></i></a>';
1533 $items .= '</li>';
1534 }
1535 if ($items === '') {
1536 return '';
1537 }
1538 $html = '<ul class="dbx-shop-tree-products">' . $items . '</ul>';
1539 return $asListItem ? '<li class="dbx-shop-tree-product-list">' . $html . '</li>' : $html;
1540 };
1541
1542 $renderGroup = function(array $group) use (&$renderGroup, &$groupsByParent, $renderProducts): string {
1543 $id = (int)($group['id'] ?? 0);
1544 if ($id <= 0) return '';
1545 $title = trim((string)($group['title'] ?? 'Artikelgruppe'));
1546 $childHtml = '';
1547 foreach (($groupsByParent[$id] ?? array()) as $child) {
1548 $childHtml .= $renderGroup($child);
1549 }
1550 $productsHtml = $renderProducts($id, true);
1551 $countChildren = count($groupsByParent[$id] ?? array());
1552 $countProducts = substr_count($productsHtml, 'data-shop-tree-node="product"');
1553 $hasChildren = $childHtml !== '' || $productsHtml !== '';
1554 $html = '<li class="dbx-shop-tree-group" data-shop-tree-group-wrap data-shop-tree-search-text="' . $this->h($title) . '">';
1555 $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 . '">';
1556 $html .= '<span class="dbx-shop-tree-group-main">';
1557 if ($hasChildren) {
1558 $html .= '<button type="button" class="dbx-shop-tree-group-toggle" data-shop-tree-group-toggle title="Gruppe ein- oder ausklappen" aria-label="Gruppe ein- oder ausklappen" aria-expanded="true"><i class="bi bi-chevron-down"></i></button>';
1559 } else {
1560 $html .= '<span class="dbx-shop-tree-toggle-spacer"></span>';
1561 }
1562 $html .= '<i class="bi bi-folder2"></i><span><strong>' . $this->h($title) . '</strong><small>' . $countChildren . ' Gruppen, ' . $countProducts . ' Artikel</small></span></span>';
1563 $html .= '<a class="btn btn-outline-secondary btn-sm openWin dbx-win" href="?dbx_modul=dbxShop_admin&amp;dbx_run1=groups&amp;dbx_ajax=1" data-url="?dbx_modul=dbxShop_admin&amp;dbx_run1=groups&amp;dbx_ajax=1" data-title="Artikelgruppen bearbeiten" data-width="54%" data-height="84%" title="Artikelgruppen bearbeiten"><i class="bi bi-diagram-3"></i></a>';
1564 $html .= '</div>';
1565 if ($hasChildren) {
1566 $html .= '<ul class="dbx-shop-tree-children">' . $childHtml . $productsHtml . '</ul>';
1567 }
1568 $html .= '</li>';
1569 return $html;
1570 };
1571
1572 $rootGroups = '';
1573 foreach (($groupsByParent[0] ?? array()) as $group) {
1574 $rootGroups .= $renderGroup($group);
1575 }
1576 $ungrouped = $renderProducts(0, false);
1577 $search = dbx()->search_html(dbx()->search_defaults(array(
1578 'name' => 'shop_tree_search',
1579 'placeholder' => 'Artikelgruppe oder Artikel suchen',
1580 'title' => 'Tree durchsuchen',
1581 'wrap_class' => 'dbx-shop-tree-search-wrap',
1582 'extra_attrs' => 'data-shop-tree-search',
1583 'i' => 1,
1584 )));
1585 $html = '<section class="dbx-shop-product-tree-panel" data-shop-tree-panel data-shop-tree-moveurl="?dbx_modul=dbxShop_admin&amp;dbx_run1=product_tree_move" aria-label="Artikelgruppen-Baum">';
1586 $html .= '<div class="dbx-shop-product-tree-head"><div><h3>Artikelgruppen-Baum</h3><p>Artikel zuordnen, Gruppen verschieben.</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&amp;dbx_ajax=1" data-url="?dbx_modul=dbxShop_admin&amp;dbx_run1=groups&amp;dbx_ajax=1" data-title="Artikelgruppen bearbeiten" data-width="54%" data-height="84%"><i class="bi bi-diagram-3"></i> Gruppen bearbeiten</a><button type="button" class="btn btn-outline-secondary btn-sm" data-shop-tree-close title="Tree schliessen" aria-label="Tree schliessen"><i class="bi bi-x-lg"></i></button></div></div>';
1587 $html .= '<div class="dbx-shop-tree-tools">' . $search . '</div>';
1588 $html .= '<ul class="dbx-shop-tree-list">' . $rootGroups . '</ul>';
1589 if ($ungrouped !== '') {
1590 $html .= '<div class="dbx-shop-tree-ungrouped"><strong>Ohne Artikelgruppe</strong>' . $ungrouped . '</div>';
1591 }
1592 $html .= '</section>';
1593 return $html;
1594 }
1595
1596 private function productTreeToggleButton(): string {
1597 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="Artikelgruppen-Baum anzeigen" aria-label="Artikelgruppen-Baum anzeigen" aria-expanded="false"><i class="bi bi-diagram-3"></i></button>';
1598 }
1599
1600 private function selectedProductIds($report): array {
1601 $ids = array();
1602 foreach (array_keys($report->get_multi_selects()) as $id) {
1603 $id = (int)$id;
1604 if ($id > 0) {
1605 $ids[$id] = $id;
1606 }
1607 }
1608 return array_values($ids);
1609 }
1610
1611 private function productReportActionControls(string $baseAction): string {
1612 $channels = '<option value="">Channel</option>';
1613 foreach ($this->repo()->channels() as $channel) {
1614 $key = (string)($channel['channel_key'] ?? '');
1615 if ($key === '') {
1616 continue;
1617 }
1618 $channels .= '<option value="' . $this->h($key) . '">' . $this->h($channel['title'] ?? $key) . '</option>';
1619 }
1620
1621 $groups = '<option value="0">Produktgruppe</option>';
1622 foreach ($this->repo()->groups() as $group) {
1623 $id = (int)($group['id'] ?? 0);
1624 if ($id <= 0) {
1625 continue;
1626 }
1627 $groups .= '<option value="' . $id . '">' . $this->h($group['title'] ?? '') . '</option>';
1628 }
1629
1630 $url = function(string $do, array $params = array()) use ($baseAction): string {
1631 $query = $baseAction . '&dbx_do=' . rawurlencode($do);
1632 foreach ($params as $key => $value) {
1633 $query .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
1634 }
1635 return $this->h($query);
1636 };
1637
1638 $actions = '<option value="">Aktion waehlen</option>'
1639 . '<option value="shop_products_delete">Ausgewaehlte Produkte loeschen</option>'
1640 . '<option value="shop_products_channel_add">Channel hinzufuegen</option>'
1641 . '<option value="shop_products_channel_remove">Channel entfernen</option>'
1642 . '<option value="shop_products_channel_export">Channel exportieren</option>'
1643 . '<option value="shop_products_group_set">Produktgruppe setzen</option>';
1644
1645 return '<div class="dbx-shop-products-bulk-actions">'
1646 . '<select class="form-select form-select-sm" name="dbx_products_bulk_action" title="Mehrfachaktion">' . $actions . '</select>'
1647 . '<select class="form-select form-select-sm" name="dbx_action_channel" title="Channel fuer Mehrfachaktion">' . $channels . '</select>'
1648 . '<select class="form-select form-select-sm" name="dbx_action_group" title="Produktgruppe fuer Mehrfachaktion">' . $groups . '</select>'
1649 . '<a class="btn btn-primary btn-sm dbxAjaxFormAction dbxConfirm" href="' . $url('shop_products_apply') . '" data-confirm-title="<i class=\'bi bi-lightning-fill\'></i> Mehrfachaktion ausfuehren" data-confirm="Aktion auf die ausgewaehlten Produkte anwenden?" data-confirm-hint="<small>Bei Loeschen werden die Produkte in den Papierkorb verschoben. Fuer Channel- und Gruppenaktionen bitte das passende Auswahlfeld setzen.</small>" data-confirm-buttons="yesno" role="button"><i class="bi bi-check2-circle"></i> Ausfuehren</a>'
1650 . '</div>';
1651 }
1652
1653 private function handleProductReportAction($report): void {
1654 $do = (string)dbx()->get_modul_var('dbx_do', '', 'parameter');
1655 $rid = (int)dbx()->get_modul_var('rid', 0, 'int');
1656 if ($do === 'row_delete') {
1657 if ($rid <= 0) {
1658 $report->_msg_error = 'Artikel konnte nicht geloescht werden.';
1659 return;
1660 }
1661 $count = $this->repo()->deleteProducts(array($rid));
1662 $report->del_multi_select($rid);
1663 $report->_msg_success = $count === 1 ? 'Artikel geloescht.' : '';
1664 $report->_msg_error = $count === 1 ? '' : 'Artikel konnte nicht geloescht werden.';
1665 return;
1666 }
1667
1668 if ($do === 'shop_products_apply') {
1669 $do = (string)dbx()->get_modul_var('dbx_products_bulk_action', '', 'parameter');
1670 }
1671
1672 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)) {
1673 if ((string)dbx()->get_modul_var('dbx_do', '', 'parameter') === 'shop_products_apply') {
1674 $report->_msg_error = 'Bitte eine Aktion auswaehlen.';
1675 }
1676 return;
1677 }
1678
1679 $ids = $this->selectedProductIds($report);
1680 if ($ids === array()) {
1681 $report->_msg_error = 'Bitte zuerst Produkte auswaehlen.';
1682 return;
1683 }
1684
1685 if ($do === 'shop_products_delete') {
1686 $count = $this->repo()->deleteProducts($ids);
1687 foreach ($ids as $id) {
1688 $report->del_multi_select($id);
1689 }
1690 $report->_msg_success = $count === 1 ? '1 Produkt geloescht.' : $count . ' Produkte geloescht.';
1691 return;
1692 }
1693
1694 if ($do === 'shop_products_channel_add') {
1695 $channel = trim((string)dbx()->get_modul_var('dbx_action_channel', '', 'parameter'));
1696 if ($channel === '') {
1697 $report->_msg_error = 'Bitte einen Channel auswaehlen.';
1698 return;
1699 }
1700 $count = $this->repo()->addChannelToProducts($ids, $channel);
1701 $report->_msg_success = $count . ' Produkte fuer Channel ' . $this->h($channel) . ' aktiviert.';
1702 return;
1703 }
1704
1705 if ($do === 'shop_products_channel_remove') {
1706 $channel = trim((string)dbx()->get_modul_var('dbx_action_channel', '', 'parameter'));
1707 if ($channel === '') {
1708 $report->_msg_error = 'Bitte einen Channel auswaehlen.';
1709 return;
1710 }
1711 $count = $this->repo()->removeChannelFromProducts($ids, $channel);
1712 $report->_msg_success = $count . ' Produkte aus Channel ' . $this->h($channel) . ' entfernt.';
1713 return;
1714 }
1715
1716 if ($do === 'shop_products_channel_export') {
1717 $channel = trim((string)dbx()->get_modul_var('dbx_action_channel', '', 'parameter'));
1718 if ($channel === '') {
1719 $report->_msg_error = 'Bitte einen Channel auswaehlen.';
1720 return;
1721 }
1722 $summary = $this->repo()->exportProductsToChannel($ids, $channel);
1723 $report->_msg_success = (int)($summary['ok'] ?? 0) . ' Export(e) erfolgreich, ' . (int)($summary['failed'] ?? 0) . ' fehlgeschlagen.';
1724 if (!empty($summary['messages'])) {
1725 $report->_msg_info = implode('<br>', array_map(fn($msg) => $this->h($msg), array_slice((array)$summary['messages'], 0, 8)));
1726 }
1727 return;
1728 }
1729
1730 if ($do === 'shop_products_group_set') {
1731 $groupId = (int)dbx()->get_modul_var('dbx_action_group', 0, 'int');
1732 if ($groupId <= 0) {
1733 $report->_msg_error = 'Bitte eine Produktgruppe auswaehlen.';
1734 return;
1735 }
1736 $count = $this->repo()->setProductGroupForProducts($ids, $groupId);
1737 $report->_msg_success = $count . ' Produkte der Produktgruppe zugeordnet.';
1738 }
1739 }
1740
1741 private function cardTemplateOptions(string $selected): string {
1742 return $this->optionsHtml(array(
1743 'product-card-default' => 'Standardkarte',
1744 'product-card-compact' => 'Kompaktkarte',
1745 ), $selected);
1746 }
1747
1748 private function detailTemplateOptions(string $selected): string {
1749 return $this->optionsHtml(array(
1750 'product-detail-default' => 'Standarddetail',
1751 'product-detail-technical' => 'Technische Ansicht',
1752 ), $selected);
1753 }
1754
1755 private function galleryTemplateOptions(string $selected): string {
1756 return $this->optionsHtml(array(
1757 'image-gallery' => 'Bild Gallery',
1758 'file-gallery' => 'Datei Gallery',
1759 ), $selected);
1760 }
1761
1762 private function galleryOverflowOptions(string $selected): string {
1763 return $this->optionsHtml(array(
1764 'grid' => 'Grid',
1765 'slider' => 'Slider',
1766 'scroll' => 'Scroll',
1767 'laufband' => 'Laufband',
1768 'tutorial' => 'Tutorial',
1769 ), $selected);
1770 }
1771
1772 private function galleryClickOptions(string $selected): string {
1773 return $this->optionsHtml(array(
1774 'lightbox' => 'Lightbox',
1775 'none' => 'Kein Klick',
1776 'newtab' => 'Neuer Tab',
1777 'viewerjs' => 'ViewerJS',
1778 'photoswipe' => 'PhotoSwipe',
1779 ), $selected);
1780 }
1781
1782 private function shopAdminStyle(): string {
1783 $file = dirname(__DIR__) . '/design/css/shop-admin.css';
1784 if (!is_file($file)) {
1785 return '';
1786 }
1787 return '<style>' . file_get_contents($file) . '</style>';
1788 }
1789
1790 private function frame(string $content, string $title = 'Shop Administration', string $barActions = ''): string {
1791 return $this->tpl()->get_tpl('dbxShop_admin|admin-shell', array(
1792 'shop_admin_style' => $this->shopAdminStyle(),
1793 'bar_title' => $this->h($title),
1794 'bar_icon' => 'bi-bag-check',
1795 'bar_subtitle' => 'Produkte, Bestellungen, Rechtstexte und Widerrufe',
1796 'bar_title_pre' => '',
1797 'bar_title_heading_attrs' => '',
1798 'bar_middle' => '',
1799 'bar_extra' => '',
1800 'bar_actions' => $barActions,
1801 'content' => $content,
1802 ));
1803 }
1804
1805 private function productBarActions(): string {
1806 return '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=0" title="Neuen Artikel anlegen"><i class="bi bi-plus-square"></i><span class="visually-hidden"> Neuer Artikel</span></a>'
1807 . $this->helpButton($this->ensureShopProductsHelpPage(), 'Hilfe: Produkte');
1808 }
1809
1810 private function productShellActions(): string {
1811 return $this->productBarActions();
1812 }
1813
1814 private function productFormDefaults(int $id): array {
1815 $now = date('Y-m-d H:i:s');
1816 $uid = (int)dbx()->user();
1817 $defaults = array(
1818 'update_date' => $now,
1819 'update_uid' => $uid,
1820 );
1821 if ($id <= 0) {
1822 $defaults['create_date'] = $now;
1823 $defaults['create_uid'] = $uid;
1824 $defaults['owner'] = $uid;
1825 $defaults['trash'] = 0;
1826 $defaults['currency'] = 'EUR';
1827 }
1828 return $defaults;
1829 }
1830
1831 private function newProductDefaults(): array {
1832 return array(
1833 'sku' => '',
1834 'slug' => '',
1835 'title' => '',
1836 'category' => 'Merchandise',
1837 'product_type' => 'physical',
1838 'summary' => '',
1839 'description' => '',
1840 'price_gross' => '0.00',
1841 'currency' => 'EUR',
1842 'tax_mode' => 'group',
1843 'tax_rate' => '-1',
1844 'shipping_mode' => 'group',
1845 'shipping_gross' => '-1',
1846 'stock' => '0',
1847 'active' => 1,
1848 'sorter' => 100,
1849 'badge' => '',
1850 'image_icon' => 'bi-box-seam',
1851 'logo_variant' => '',
1852 );
1853 }
1854
1855 private function applyProductPreset(array $data): array {
1856 $preset = trim((string)dbx()->get_modul_var('workflow_preset', '', 'parameter'));
1857 if ($preset === 'shop_article_publish') {
1858 $data['category'] = $data['category'] ?: 'Merchandise';
1859 $data['product_type'] = $data['product_type'] ?: 'physical';
1860 $data['active'] = 1;
1861 }
1862
1863 $map = array(
1864 'preset_sku' => 'sku',
1865 'preset_slug' => 'slug',
1866 'preset_title' => 'title',
1867 'preset_category' => 'category',
1868 'preset_product_type' => 'product_type',
1869 'preset_summary' => 'summary',
1870 'preset_price_gross' => 'price_gross',
1871 'preset_group_id' => 'product_group_id',
1872 );
1873 foreach ($map as $param => $field) {
1874 $value = trim((string)dbx()->get_modul_var($param, '', '*'));
1875 if ($value !== '') {
1876 $data[$field] = $value;
1877 }
1878 }
1879
1880 return $data;
1881 }
1882
1883 private function productFormActions(int $id): string {
1884 $html = $this->helpButton($this->ensureShopProductsHelpPage(), 'Hilfe: Artikel bearbeiten', 'btn btn-outline-secondary btn-sm ms-1')
1885 . '<button class="btn btn-primary btn-sm" type="submit" title="Artikel speichern"><i class="bi bi-save"></i><span class="visually-hidden"> Speichern</span></button>';
1886
1887 if ($id > 0) {
1888 $product = $this->repo()->productById($id);
1889 $previewUrl = '?dbx_modul=dbxShop&dbx_run1=product&sku=' . rawurlencode((string)($product['sku'] ?? ''));
1890 $deleteUrl = '?dbx_modul=dbxShop_admin&dbx_run1=products&dbx_do=row_delete&rid=' . $id . '&dbx_ajax=1';
1891 $html .= $this->openWinButton($previewUrl, 'Artikel ansehen', '<i class="bi bi-search"></i><span class="visually-hidden"> Vorschau</span>', 'btn btn-outline-primary btn-sm ms-1', '82%', '82%');
1892 $html .= $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=product_attributes&id=' . $id . '&dbx_ajax=1', 'Artikelattribute', '<i class="bi bi-sliders"></i><span class="visually-hidden"> Attribute</span>', 'btn btn-outline-primary btn-sm ms-1', '76%', '78%');
1893 $html .= '<a class="btn btn-outline-danger btn-sm ms-1 dbxConfirm" href="' . $this->h($deleteUrl) . '" data-confirm-title="Artikel loeschen" data-confirm="Diesen Artikel wirklich loeschen?" data-confirm-buttons="yesno" title="Artikel loeschen"><i class="bi bi-trash"></i><span class="visually-hidden"> Loeschen</span></a>';
1894 }
1895
1896 $html .= '<a class="btn btn-outline-secondary btn-sm ms-1" href="?dbx_modul=dbxShop_admin&dbx_run1=products" title="Zur Produktliste"><i class="bi bi-table"></i><span class="visually-hidden"> Produktliste</span></a>';
1897
1898 return $html;
1899 }
1900
1901 private function productGroupOptions(int $excludeId = 0, bool $withNone = false): array {
1902 $groups = $this->repo()->groups();
1903 $byParent = array();
1904 foreach ($groups as $group) {
1905 $parentId = (int)($group['parent_id'] ?? 0);
1906 $byParent[$parentId][] = $group;
1907 }
1908
1909 $options = $withNone ? array('0' => 'Keine Obergruppe') : array();
1910 $walk = function(int $parentId, string $prefix) use (&$walk, &$options, $byParent, $excludeId): void {
1911 foreach (($byParent[$parentId] ?? array()) as $group) {
1912 $id = (int)($group['id'] ?? 0);
1913 if ($id <= 0 || $id === $excludeId) {
1914 continue;
1915 }
1916 $title = trim((string)($group['title'] ?? ''));
1917 if ($title === '') {
1918 $title = (string)($group['group_key'] ?? $id);
1919 }
1920 $label = $prefix !== '' ? $prefix . ' / ' . $title : $title;
1921 $options[(string)$id] = $label;
1922 $walk($id, $label);
1923 }
1924 };
1925 $walk(0, '');
1926 return $options;
1927 }
1928
1929 private function shopMediaConfig(): array {
1930 return array(
1931 'media' => $this->cmsEndpoint('cms_media', array('images' => 1, 'media_type' => 'image')),
1932 'uploadmediafolder' => 'img/shop',
1933 'upload' => $this->cmsEndpoint('cms_upload'),
1934 'externalvideo' => $this->cmsEndpoint('cms_external_video'),
1935 'mediafolders' => $this->cmsEndpoint('cms_media_folders'),
1936 'mediafoldercreate' => $this->cmsEndpoint('cms_media_folder_create'),
1937 'mediafolderdelete' => $this->cmsEndpoint('cms_media_folder_delete'),
1938 'mediafolderrename' => $this->cmsEndpoint('cms_media_folder_rename'),
1939 'mediamove' => $this->cmsEndpoint('cms_media_move'),
1940 'mediaunused' => $this->cmsEndpoint('cms_media_unused'),
1941 'mediaprocess' => $this->cmsEndpoint('cms_media_process'),
1942 'deletemedia' => $this->cmsEndpoint('cms_delete_media'),
1943 'editmedia' => $this->cmsEndpoint('cms_edit_media'),
1944 'assignurl' => $this->shopEndpoint('assign_media'),
1945 );
1946 }
1947
1948 private function shopMediaAttrs(array $mediaCfg): string {
1949 $attrs = ' data-dbx="lib=shopAdmin"';
1950 foreach ($mediaCfg as $key => $value) {
1951 $attrs .= ' data-shop-' . $this->h($key) . '="' . $this->h($value) . '"';
1952 }
1953 return $attrs;
1954 }
1955
1956 private function productImagesPanel(array $product, bool $isNew): string {
1957 if ($isNew) {
1958 return '<aside class="border rounded bg-light p-3"><h6 class="mb-3">Artikelbilder</h6><div class="alert alert-info mb-0">Artikel erst speichern, danach koennen Bilder zugeordnet werden.</div></aside>';
1959 }
1960
1961 $productId = (int)($product['id'] ?? 0);
1962 $this->ensureCmsShopMediaFolder();
1963 $mediaCfg = $this->shopMediaConfig();
1964 $html = '<aside class="border rounded bg-light p-3 dbx-shop-media-manager dbx-shop-product-image-panel"' . $this->shopMediaAttrs($mediaCfg) . '>';
1965 $html .= '<input type="hidden" value="' . $productId . '" data-shop-product-select>';
1966 $html .= '<input type="hidden" value="0" data-shop-group-select>';
1967 $html .= '<input type="hidden" value="100" data-shop-sorter>';
1968 $html .= '<div class="d-flex justify-content-between align-items-center gap-2 mb-3">';
1969 $html .= '<h6 class="mb-0">Artikelbilder</h6>';
1970 $html .= '<button type="button" class="btn btn-outline-primary btn-sm dbx-shop-media-pick" data-shop-media-folder="img/shop" title="Bilder aus dem Medienbrowser zum Artikel hinzufuegen"><i class="bi bi-images"></i><i class="bi bi-camera-video"></i><i class="bi bi-upload"></i><span>Auswahl</span></button>';
1971 $html .= '</div>';
1972 $html .= '<label class="form-check mb-3"><input class="form-check-input" type="checkbox" value="1" data-shop-primary> <span class="form-check-label">neues Bild als Primaerbild</span></label>';
1973
1974 $images = (array)($product['images'] ?? array());
1975 if ($images === array()) {
1976 $html .= '<div class="text-muted small">Noch keine Bilder zugeordnet.</div>';
1977 } else {
1978 $html .= '<div class="dbx-shop-image-list">';
1979 foreach ($images as $image) {
1980 $imageId = (int)($image['id'] ?? 0);
1981 $source = (int)($image['product_id'] ?? 0) === $productId ? 'Artikelbild' : 'Gruppenbild';
1982 $primary = (int)($image['is_primary'] ?? 0) === 1 ? '<span class="badge text-bg-primary ms-1">Primaer</span>' : '';
1983 $title = trim((string)($image['title'] ?? ''));
1984 if ($title === '') {
1985 $title = basename((string)($image['image_path'] ?? 'Bild'));
1986 }
1987 $removeUrl = '?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $productId . '&remove_image=' . $imageId . '&dbx_ajax=1';
1988 $html .= '<figure class="dbx-shop-image-card">';
1989 if ($imageId > 0) {
1990 $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> Bild-Zuordnung aufheben" data-confirm="Diese Bild-Zuordnung wirklich aufheben?" data-confirm-hint="<small>Die Datei im Medienbrowser bleibt erhalten. Entfernt wird nur die Shop-Zuordnung. Bei Gruppenbildern betrifft das die Gruppe und damit auch andere Artikel dieser Gruppe.</small>" data-confirm-buttons="yesno" title="Bild-Zuordnung aufheben"><i class="bi bi-trash"></i><span class="visually-hidden"> Zuordnung aufheben</span></a>';
1991 }
1992 $html .= '<img src="' . $this->h($this->mediaItemUrl($image, true)) . '" alt="' . $this->h($image['alt'] ?? $title) . '">';
1993 $html .= '<figcaption><strong>' . $this->h($title) . '</strong><br><span class="text-muted">' . $this->h($source) . '</span>' . $primary . '</figcaption>';
1994 $html .= '</figure>';
1995 }
1996 $html .= '</div>';
1997 }
1998
1999 $html .= '<div class="form-text mt-3">Upload, Auswahl, Zuschneiden, Resize, Batch und Wartung laufen ueber den vorhandenen Medienbrowser.</div>';
2000 $html .= '</aside>';
2001 return $html;
2002 }
2003
2004 private function productGroupImagePanel(array $group, bool $isNew): string {
2005 if ($isNew) {
2006 return '<div class="dbx-shop-group-image-panel dbx-shop-group-image-empty"><div class="form-text">Gruppe erst speichern, danach kann ein Gruppenbild ausgewaehlt werden.</div></div>';
2007 }
2008
2009 $groupId = (int)($group['id'] ?? 0);
2010 if ($groupId <= 0) {
2011 return '';
2012 }
2013
2014 $this->ensureCmsShopMediaFolder();
2015 $mediaCfg = $this->shopMediaConfig();
2016 $image = $this->repo()->primaryImageForGroup($groupId);
2017 $html = '<div class="dbx-shop-media-manager dbx-shop-group-image-panel"' . $this->shopMediaAttrs($mediaCfg) . '>';
2018 $html .= '<input type="hidden" value="0" data-shop-product-select>';
2019 $html .= '<input type="hidden" value="' . $groupId . '" data-shop-group-select>';
2020 $html .= '<input type="hidden" value="10" data-shop-sorter>';
2021 $html .= '<input type="hidden" value="1" data-shop-primary>';
2022 $html .= '<div class="dbx-shop-group-image-head">';
2023 $html .= '<span>Gruppenbild</span>';
2024 $html .= '<button type="button" class="btn btn-outline-primary btn-sm dbx-shop-media-pick" data-shop-media-folder="img/shop" title="Gruppenbild aus dem Medienbrowser auswaehlen"><i class="bi bi-images"></i><span>Auswahl</span></button>';
2025 $html .= '</div>';
2026 if (is_array($image)) {
2027 $title = trim((string)($image['title'] ?? ''));
2028 if ($title === '') {
2029 $title = basename((string)($image['image_path'] ?? 'Gruppenbild'));
2030 }
2031 $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>';
2032 } else {
2033 $html .= '<div class="dbx-shop-group-image-placeholder"><i class="bi bi-image"></i><span>Kein Gruppenbild</span></div>';
2034 }
2035 $html .= '<div class="form-text">Ein neues Bild ersetzt das bisherige Gruppenbild. Die CMS-Datei bleibt erhalten.</div>';
2036 $html .= '</div>';
2037 return $html;
2038 }
2039
2040 private function productChannelsPanel(array $product, bool $isNew): string {
2041 if (!$this->channelsEnabled()) {
2042 return '';
2043 }
2044 if ($isNew) {
2045 return '<aside class="border rounded bg-light p-3 mt-3"><h6 class="mb-3">Channels</h6><div class="alert alert-info mb-0">Artikel erst speichern, danach koennen Channels individuell gesetzt werden.</div></aside>';
2046 }
2047
2048 $productId = (int)($product['id'] ?? 0);
2049 $overrides = $this->repo()->productChannelOverrides($productId);
2050 $inherited = $this->repo()->inheritedChannelsForProduct($productId);
2051 $html = '<aside class="border rounded bg-light p-3 mt-3 dbx-shop-product-channel-panel">';
2052 $html .= '<h6 class="mb-2">Channels</h6>';
2053 $html .= '<p class="form-text mb-3">Direkte Artikel-Auswahl. Diese Einstellung hat Vorrang vor Channel-Gruppen.</p>';
2054 $html .= '<input type="hidden" name="product_channel_editor" value="1">';
2055
2056 $channels = $this->repo()->channels();
2057 if ($channels === array()) {
2058 $html .= '<div class="text-muted small">Noch keine Channels angelegt.</div>';
2059 } else {
2060 $html .= '<div class="table-responsive dbx-shop-product-channel-table-wrap">';
2061 $html .= '<table class="table table-sm align-middle mb-0 dbx-shop-product-channel-table">';
2062 $html .= '<thead><tr><th>Channel</th><th>Status</th><th>Export</th><th class="text-end">Aktion</th></tr></thead><tbody>';
2063 foreach ($channels as $channel) {
2064 $key = trim((string)($channel['channel_key'] ?? ''));
2065 if ($key === '') {
2066 continue;
2067 }
2068
2069 $hasOverride = isset($overrides[$key]);
2070 $isInherited = isset($inherited[$key]);
2071 $checked = $hasOverride
2072 ? (int)($overrides[$key]['active'] ?? 0) === 1
2073 : $isInherited;
2074 $source = $hasOverride
2075 ? ((int)($overrides[$key]['active'] ?? 0) === 1 ? 'direkt aktiv' : 'direkt deaktiviert')
2076 : ($isInherited ? 'aus Gruppe: ' . implode(', ', array_values($inherited[$key]['group_titles'] ?? array())) : 'nicht gesetzt');
2077 $sourceText = (!$hasOverride && $isInherited) ? 'aus Gruppe' : $source;
2078 $statusClass = $checked ? 'text-bg-success' : 'text-bg-secondary';
2079 $export = $overrides[$key] ?? array();
2080 $exportStatus = trim((string)($export['export_status'] ?? ''));
2081 $exportMessage = trim((string)($export['export_message'] ?? ''));
2082 $listingId = trim((string)($export['external_listing_id'] ?? ''));
2083 $exportBadgeClass = match ($exportStatus) {
2084 'published', 'exported', 'ready', 'manual_ready' => 'text-bg-info',
2085 'failed' => 'text-bg-danger',
2086 default => 'text-bg-light text-dark',
2087 };
2088 $exportText = $exportStatus !== '' ? $exportStatus : 'nicht exportiert';
2089 $exportUrl = '?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $productId . '&export_channel=' . rawurlencode($key) . '&dbx_ajax=1';
2090 $mappingUrl = '?dbx_modul=dbxShop_admin&dbx_run1=product_channel_mapping&id=' . $productId . '&channel=' . rawurlencode($key) . '&dbx_ajax=1';
2091
2092 $html .= '<tr class="dbx-shop-product-channel-row">';
2093 $html .= '<td class="dbx-shop-product-channel-name">';
2094 $html .= '<label class="d-flex align-items-start gap-2 mb-0">';
2095 $html .= '<input class="form-check-input" type="checkbox" name="product_channels[]" value="' . $this->h($key) . '"' . ($checked ? ' checked' : '') . '>';
2096 $html .= '<span class="dbx-shop-product-channel-copy"><strong>' . $this->h($channel['title'] ?? $key) . '</strong><code>' . $this->h($key) . '</code></span>';
2097 $html .= '</label>';
2098 $html .= '</td>';
2099 $html .= '<td><span class="badge ' . $statusClass . '" title="' . $this->h($source) . '">' . $this->h($sourceText) . '</span></td>';
2100 $html .= '<td><span class="badge ' . $exportBadgeClass . '" title="' . $this->h($exportMessage) . '">' . $this->h($exportText) . '</span>';
2101 if ($listingId !== '') {
2102 $html .= '<code class="small d-block mt-1">' . $this->h($listingId) . '</code>';
2103 }
2104 $html .= '</td>';
2105 $html .= '<td class="text-end"><span class="dbx-shop-product-channel-actions">';
2106 $html .= $this->openWinButton($mappingUrl, 'Channel-Mapping: ' . (string)($channel['title'] ?? $key), '<i class="bi bi-sliders2"></i><span class="visually-hidden"> Mapping</span>', 'btn btn-outline-secondary btn-sm', '68%', '84%');
2107 if ($checked && (int)($channel['export_enabled'] ?? 0) === 1) {
2108 $html .= '<a class="btn btn-outline-primary btn-sm dbxConfirm" href="' . $this->h($exportUrl) . '" data-confirm-title="<i class=\'bi bi-broadcast\'></i> Channel exportieren" data-confirm="Artikel jetzt zu ' . $this->h($channel['title'] ?? $key) . ' exportieren?" data-confirm-hint="<small>Der Connector sendet nur, wenn die fuer den Anbieter notwendigen Zugangsdaten und Pflichtfelder vorhanden sind. Status und Antwort werden am Artikel gespeichert.</small>" data-confirm-buttons="yesno" title="Zu diesem Channel exportieren"><i class="bi bi-broadcast"></i></a>';
2109 }
2110 $html .= '</span></td>';
2111 $html .= '</tr>';
2112 }
2113 $html .= '</tbody></table></div>';
2114 }
2115
2116 $html .= '</aside>';
2117 return $html;
2118 }
2119
2120 private function mappingLinesToMap(string $lines): array {
2121 $map = array();
2122 foreach (preg_split('/\R/', $lines) ?: array() as $line) {
2123 $line = trim((string)$line);
2124 if ($line === '' || strpos($line, '=') === false) {
2125 continue;
2126 }
2127 [$key, $value] = array_map('trim', explode('=', $line, 2));
2128 if ($key !== '' && $value !== '') {
2129 $map[$key] = $value;
2130 }
2131 }
2132 return $map;
2133 }
2134
2135 private function mappingMapToLines(array $map): string {
2136 $lines = array();
2137 foreach ($map as $key => $value) {
2138 if (is_array($value)) {
2139 $value = implode('|', array_map('strval', $value));
2140 }
2141 $lines[] = (string)$key . '=' . (string)$value;
2142 }
2143 return implode("\n", $lines);
2144 }
2145
2146 private function ebayCategoryOptions(array $mapping, array $channel, array $product): array {
2147 $options = array();
2148 $add = function(string $value, string $label) use (&$options) {
2149 $value = trim($value);
2150 if ($value === '' || isset($options[$value])) {
2151 return;
2152 }
2153 $options[$value] = $label;
2154 };
2155
2156 $current = (string)($mapping['category_id'] ?? '');
2157 $channelDefault = (string)($channel['category_id'] ?? '');
2158 $groupDefault = (string)($this->productGroupChannelMappingDefaults('ebay', $product)['category_id'] ?? '');
2159 $add($current, 'Aktuelle Auswahl: ' . $current);
2160 $add($groupDefault, 'Artikelgruppen-Vorgabe: ' . $groupDefault);
2161 $add($channelDefault, 'Channel-Vorgabe: ' . $channelDefault);
2162
2163 $configured = dbx()->get_config('dbxShop', 'ebay_category_options');
2164 if (is_array($configured)) {
2165 foreach ($configured as $value => $label) {
2166 $add((string)$value, (string)$label);
2167 }
2168 } else {
2169 foreach (preg_split('/\R/', (string)$configured) ?: array() as $line) {
2170 $line = trim($line);
2171 if ($line === '') {
2172 continue;
2173 }
2174 if (strpos($line, '=') !== false) {
2175 [$value, $label] = array_map('trim', explode('=', $line, 2));
2176 $add($value, $label !== '' ? $label : $value);
2177 } else {
2178 $add($line, $line);
2179 }
2180 }
2181 }
2182
2183 $productCategory = strtolower((string)($product['category'] ?? '') . ' ' . (string)($product['product_type'] ?? ''));
2184 if (strpos($productCategory, 'software') !== false || strpos($productCategory, 'digital') !== false) {
2185 $add('58058', '58058 - Software / digitale Produkte');
2186 }
2187
2188 $add('58058', '58058 - Software / digitale Produkte');
2189 $add('11450', '11450 - Kleidung & Accessoires');
2190 $add('293', '293 - Elektronik');
2191 $add('220', '220 - Haus & Garten');
2192 $add('12576', '12576 - Business & Industrie');
2193
2194 return $options;
2195 }
2196
2197 private function ebayCategoryInput(array $mapping, array $channel, array $product): string {
2198 $value = (string)($mapping['category_id'] ?? $channel['category_id'] ?? '');
2199 $html = '<div class="col-md-4"><label class="form-label">eBay Kategorie</label>'
2200 . '<select class="form-select form-select-sm" name="mapping_category_id">';
2201 foreach ($this->ebayCategoryOptions($mapping, $channel, $product) as $optionValue => $label) {
2202 $html .= '<option value="' . $this->h($optionValue) . '"' . ((string)$optionValue === $value ? ' selected' : '') . '>' . $this->h($label) . '</option>';
2203 }
2204 $html .= '</select>'
2205 . '<div class="form-text">Die Liste ist eine Vorgabe. Fuer weitere IDs koennen Shop-Settings oder spaeter die eBay-Taxonomy-Anbindung genutzt werden.</div>'
2206 . '</div>';
2207 return $html;
2208 }
2209
2210 private function productGroupChannelMappingDefaults(string $platform, array $product): array {
2211 $groupId = (int)($product['product_group_id'] ?? 0);
2212 if ($groupId <= 0) {
2213 return array();
2214 }
2215 $group = $this->repo()->groupById($groupId);
2216 if (!is_array($group)) {
2217 return array();
2218 }
2219
2220 if ($platform === 'ebay') {
2221 $category = trim((string)($group['ebay_category_id'] ?? ''));
2222 return $category !== '' ? array('category_id' => $category) : array();
2223 }
2224 if ($platform === 'amazon') {
2225 $productType = trim((string)($group['amazon_product_type'] ?? ''));
2226 return $productType !== '' ? array('productType' => $productType) : array();
2227 }
2228 if ($platform === 'kleinanzeigen') {
2229 $category = trim((string)($group['kleinanzeigen_category_id'] ?? ''));
2230 return $category !== '' ? array('category_id' => $category) : array();
2231 }
2232 if ($platform === 'mobile') {
2233 $category = trim((string)($group['mobile_category_id'] ?? ''));
2234 return $category !== '' ? array('mobile_vehicle' => array('category' => $category)) : array();
2235 }
2236
2237 return array();
2238 }
2239
2240 private function channelMappingInheritedDefaults(string $platform, array $channel, array $product): array {
2241 $defaults = array();
2242 if ($platform === 'ebay') {
2243 $defaults = array(
2244 'category_id' => trim((string)($channel['category_id'] ?? '')),
2245 'payment_policy_id' => trim((string)($channel['payment_policy_id'] ?? '')),
2246 'fulfillment_policy_id' => trim((string)($channel['fulfillment_policy_id'] ?? '')),
2247 'return_policy_id' => trim((string)($channel['return_policy_id'] ?? '')),
2248 );
2249 } elseif ($platform === 'amazon') {
2250 $category = trim((string)($channel['category_id'] ?? ''));
2251 if (stripos($category, 'productType:') === 0) {
2252 $category = trim(substr($category, strlen('productType:')));
2253 }
2254 if (strpos($category, '/') !== false) {
2255 $category = trim((string)explode('/', $category)[0]);
2256 }
2257 $defaults = array('productType' => strtoupper($category));
2258 } elseif ($platform === 'kleinanzeigen') {
2259 $defaults = array(
2260 'category_id' => trim((string)($channel['category_id'] ?? '')),
2261 'location' => trim((string)($channel['location_key'] ?? '')),
2262 );
2263 } elseif ($platform === 'mobile') {
2264 $category = trim((string)($channel['category_id'] ?? ''));
2265 $defaults = array('mobile_vehicle' => array('category' => $category));
2266 }
2267
2268 $defaults = $this->mergeMappingDefaults($defaults, $this->productGroupChannelMappingDefaults($platform, $product));
2269 return $this->cleanEmptyMappingValues($defaults);
2270 }
2271
2272 private function mergeMappingDefaults(array $defaults, array $mapping): array {
2273 foreach ($defaults as $key => $value) {
2274 if (is_array($value)) {
2275 $current = is_array($mapping[$key] ?? null) ? $mapping[$key] : array();
2276 $mapping[$key] = $this->mergeMappingDefaults($value, $current);
2277 continue;
2278 }
2279 if (!array_key_exists($key, $mapping) || trim((string)$mapping[$key]) === '') {
2280 $mapping[$key] = $value;
2281 }
2282 }
2283 return $mapping;
2284 }
2285
2286 private function cleanEmptyMappingValues(array $mapping): array {
2287 foreach ($mapping as $key => $value) {
2288 if (is_array($value)) {
2289 $value = $this->cleanEmptyMappingValues($value);
2290 if ($value === array()) {
2291 unset($mapping[$key]);
2292 } else {
2293 $mapping[$key] = $value;
2294 }
2295 continue;
2296 }
2297 if (trim((string)$value) === '') {
2298 unset($mapping[$key]);
2299 }
2300 }
2301 return $mapping;
2302 }
2303
2304 private function removeInheritedMappingDefaults(array $mapping, array $defaults): array {
2305 foreach ($defaults as $key => $defaultValue) {
2306 if (!array_key_exists($key, $mapping)) {
2307 continue;
2308 }
2309 if (is_array($defaultValue)) {
2310 $current = is_array($mapping[$key]) ? $mapping[$key] : array();
2311 $mapping[$key] = $this->removeInheritedMappingDefaults($current, $defaultValue);
2312 if ($mapping[$key] === array()) {
2313 unset($mapping[$key]);
2314 }
2315 continue;
2316 }
2317 if (trim((string)$mapping[$key]) === trim((string)$defaultValue)) {
2318 unset($mapping[$key]);
2319 }
2320 }
2321 return $this->cleanEmptyMappingValues($mapping);
2322 }
2323
2324 private function providerMappingHtml(string $platform, array $mapping, array $channel, array $product): string {
2325 $input = function(string $name, string $label, string $value = '', string $placeholder = '', string $class = 'col-md-4'): string {
2326 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>';
2327 };
2328 $textarea = function(string $name, string $label, string $value = '', string $placeholder = '', int $rows = 4, string $class = 'col-12'): string {
2329 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>';
2330 };
2331
2332 $html = '<div class="row g-3">';
2333 if ($platform === 'ebay') {
2334 $condition = (string)($mapping['condition'] ?? 'NEW');
2335 $aspects = is_array($mapping['aspects'] ?? null) ? $this->mappingMapToLines($mapping['aspects']) : '';
2336 $locationKey = trim((string)($channel['location_key'] ?? ''));
2337 $html .= $this->ebayCategoryInput($mapping, $channel, $product);
2338 $html .= '<div class="col-md-4"><label class="form-label">Location-Key</label>'
2339 . '<input class="form-control form-control-sm" value="' . $this->h($locationKey) . '" placeholder="in Channel-Konfiguration setzen" readonly>'
2340 . '<div class="form-text">Kommt aus der eBay-Channel-Konfiguration.</div></div>';
2341 $html .= '<div class="col-md-4"><label class="form-label">Zustand</label><select class="form-select form-select-sm" name="mapping_condition">'
2342 . '<option value="NEW"' . ($condition === 'NEW' ? ' selected' : '') . '>Neu</option>'
2343 . '<option value="USED_EXCELLENT"' . ($condition === 'USED_EXCELLENT' ? ' selected' : '') . '>Gebraucht sehr gut</option>'
2344 . '<option value="USED_GOOD"' . ($condition === 'USED_GOOD' ? ' selected' : '') . '>Gebraucht gut</option>'
2345 . '</select></div>';
2346 $html .= $input('payment_policy_id', 'Payment-Policy-ID', (string)($mapping['payment_policy_id'] ?? $channel['payment_policy_id'] ?? ''), 'policy_payment_1234567890');
2347 $html .= $input('fulfillment_policy_id', 'Fulfillment-Policy-ID', (string)($mapping['fulfillment_policy_id'] ?? $channel['fulfillment_policy_id'] ?? ''), 'policy_fulfillment_1234567890');
2348 $html .= $input('return_policy_id', 'Return-Policy-ID', (string)($mapping['return_policy_id'] ?? $channel['return_policy_id'] ?? ''), 'policy_return_1234567890');
2349 $html .= $textarea('aspects', 'eBay Aspekte', $aspects, "Marke=dbxApp\nFarbe=Schwarz\nGroesse=L", 5);
2350 } elseif ($platform === 'amazon') {
2351 $simple = is_array($mapping['simple_attributes'] ?? null) ? $this->mappingMapToLines($mapping['simple_attributes']) : '';
2352 $html .= $input('productType', 'Amazon Product Type', (string)($mapping['productType'] ?? $mapping['product_type'] ?? ''), 'SOFTWARE / PRODUCT / SHIRT');
2353 $html .= '<div class="col-md-4"><label class="form-label">Requirements</label><select class="form-select form-select-sm" name="mapping_requirements">'
2354 . '<option value="LISTING"' . ((string)($mapping['requirements'] ?? 'LISTING') === 'LISTING' ? ' selected' : '') . '>Listing</option>'
2355 . '<option value="LISTING_PRODUCT_ONLY"' . ((string)($mapping['requirements'] ?? '') === 'LISTING_PRODUCT_ONLY' ? ' selected' : '') . '>Produktdaten</option>'
2356 . '<option value="LISTING_OFFER_ONLY"' . ((string)($mapping['requirements'] ?? '') === 'LISTING_OFFER_ONLY' ? ' selected' : '') . '>Offer</option>'
2357 . '</select></div>';
2358 $html .= $input('brand', 'Marke', (string)($mapping['simple_attributes']['brand'] ?? 'dbxApp'), 'dbxApp');
2359 $html .= $textarea('simple_attributes', 'Amazon Attribute', $simple, "manufacturer=dbxApp\nitem_type_keyword=software\nrecommended_browse_nodes=123456", 6);
2360 $html .= '<div class="col-12"><div class="form-text">Amazon verlangt je Product Type unterschiedliche Pflichtattribute. Diese Maske speichert einfache <code>key=value</code>-Werte und der Connector wandelt sie in SP-API Attribute um.</div></div>';
2361 } elseif ($platform === 'mobile') {
2362 $vehicle = is_array($mapping['mobile_vehicle'] ?? null) ? $mapping['mobile_vehicle'] : array();
2363 $html .= $input('vehicle_make', 'Marke', (string)($vehicle['make'] ?? ''), 'Volkswagen');
2364 $html .= $input('vehicle_model', 'Modell', (string)($vehicle['model'] ?? ''), 'Golf');
2365 $html .= $input('vehicle_first_registration', 'Erstzulassung', (string)($vehicle['firstRegistration'] ?? ''), '2023-05');
2366 $html .= $input('vehicle_mileage', 'Kilometer', (string)($vehicle['mileage'] ?? ''), '25000');
2367 $html .= $input('vehicle_fuel', 'Kraftstoff', (string)($vehicle['fuel'] ?? ''), 'PETROL');
2368 $html .= $input('vehicle_power', 'Leistung kW', (string)($vehicle['power'] ?? ''), '110');
2369 $html .= $input('vehicle_category', 'Fahrzeugart', (string)($vehicle['category'] ?? $channel['category_id'] ?? ''), 'car');
2370 $html .= $textarea('vehicle_extra', 'Weitere mobile.de Fahrzeugfelder', is_array($mapping['vehicle_extra'] ?? null) ? $this->mappingMapToLines($mapping['vehicle_extra']) : '', "gearbox=MANUAL\nemissionClass=EURO6", 5);
2371 $html .= '<div class="col-12"><div class="form-text">mobile.de ist nur fuer Fahrzeuganzeigen gedacht. Normale Shop-Artikel werden vom Connector bewusst blockiert.</div></div>';
2372 } elseif ($platform === 'kleinanzeigen') {
2373 $attrs = is_array($mapping['attributes'] ?? null) ? $this->mappingMapToLines($mapping['attributes']) : '';
2374 $html .= $input('category_id', 'Kategorie/Partner-Kategorie', (string)($mapping['category_id'] ?? $channel['category_id'] ?? ''), 'category_12345');
2375 $html .= $input('location', 'Ort/Standort', (string)($mapping['location'] ?? ''), '10115 Berlin');
2376 $html .= $input('contact_name', 'Kontaktname', (string)($mapping['contact_name'] ?? ''), 'Muster GmbH');
2377 $html .= $input('phone', 'Telefon', (string)($mapping['phone'] ?? ''), '+49...');
2378 $html .= $textarea('attributes', 'Kleinanzeigen/Middleware Attribute', $attrs, "zustand=neu\nversand=ja\nfarbe=schwarz", 5);
2379 $html .= '<div class="col-12"><div class="form-text">Ohne freigegebene Partner- oder Middleware-API wird kein automatischer Upload zu kleinanzeigen.de ausgefuehrt. Die Daten sind dann fuer manuelle Uebergabe vorbereitet.</div></div>';
2380 } else {
2381 $attrs = is_array($mapping['attributes'] ?? null) ? $this->mappingMapToLines($mapping['attributes']) : '';
2382 $html .= $input('endpoint_action', 'Aktion/Endpoint', (string)($mapping['endpoint_action'] ?? ''), 'products.upsert');
2383 $html .= $textarea('attributes', 'Middleware Attribute', $attrs, "external_category=123\nbrand=dbxApp", 6);
2384 }
2385 $html .= '</div>';
2386 return $html;
2387 }
2388
2389 private function collectProductChannelMapping(string $platform, array $defaults = array()): array {
2390 if ($platform === 'ebay') {
2391 return array(
2392 'category_id' => trim((string)($_POST['mapping_category_id'] ?? '')),
2393 'condition' => trim((string)($_POST['mapping_condition'] ?? 'NEW')),
2394 'payment_policy_id' => trim((string)($_POST['mapping_payment_policy_id'] ?? '')),
2395 'fulfillment_policy_id' => trim((string)($_POST['mapping_fulfillment_policy_id'] ?? '')),
2396 'return_policy_id' => trim((string)($_POST['mapping_return_policy_id'] ?? '')),
2397 'aspects' => $this->mappingLinesToMap((string)($_POST['mapping_aspects'] ?? '')),
2398 );
2399 }
2400 if ($platform === 'amazon') {
2401 $simple = $this->mappingLinesToMap((string)($_POST['mapping_simple_attributes'] ?? ''));
2402 $brand = trim((string)($_POST['mapping_brand'] ?? ''));
2403 if ($brand !== '') {
2404 $simple['brand'] = $brand;
2405 }
2406 return array(
2407 'productType' => trim((string)($_POST['mapping_productType'] ?? '')),
2408 'requirements' => trim((string)($_POST['mapping_requirements'] ?? 'LISTING')),
2409 'simple_attributes' => $simple,
2410 );
2411 }
2412 if ($platform === 'mobile') {
2413 $vehicle = array(
2414 'make' => trim((string)($_POST['mapping_vehicle_make'] ?? '')),
2415 'model' => trim((string)($_POST['mapping_vehicle_model'] ?? '')),
2416 'firstRegistration' => trim((string)($_POST['mapping_vehicle_first_registration'] ?? '')),
2417 'mileage' => trim((string)($_POST['mapping_vehicle_mileage'] ?? '')),
2418 'fuel' => trim((string)($_POST['mapping_vehicle_fuel'] ?? '')),
2419 'power' => trim((string)($_POST['mapping_vehicle_power'] ?? '')),
2420 'category' => trim((string)($_POST['mapping_vehicle_category'] ?? '')),
2421 );
2422 $vehicle = array_filter($vehicle, fn($value) => trim((string)$value) !== '');
2423 return array(
2424 'mobile_vehicle' => $vehicle,
2425 'vehicle_extra' => $this->mappingLinesToMap((string)($_POST['mapping_vehicle_extra'] ?? '')),
2426 );
2427 }
2428 if ($platform === 'kleinanzeigen') {
2429 return array(
2430 'category_id' => trim((string)($_POST['mapping_category_id'] ?? '')),
2431 'location' => trim((string)($_POST['mapping_location'] ?? '')),
2432 'contact_name' => trim((string)($_POST['mapping_contact_name'] ?? '')),
2433 'phone' => trim((string)($_POST['mapping_phone'] ?? '')),
2434 'attributes' => $this->mappingLinesToMap((string)($_POST['mapping_attributes'] ?? '')),
2435 );
2436 }
2437 return array(
2438 'endpoint_action' => trim((string)($_POST['mapping_endpoint_action'] ?? '')),
2439 'attributes' => $this->mappingLinesToMap((string)($_POST['mapping_attributes'] ?? '')),
2440 );
2441 }
2442
2443 private function normalizeDecimalInput($value): string {
2444 $value = str_replace(',', '.', trim((string)$value));
2445 if ($value === '') {
2446 return '';
2447 }
2448 return number_format((float)$value, 2, '.', '');
2449 }
2450
2451 private function channelInheritedDecimalValue(string $postedName): string {
2452 $value = $this->normalizeDecimalInput($_POST[$postedName] ?? '');
2453 $inherited = (string)($_POST[$postedName . '_inherited'] ?? '') === '1';
2454 $inheritedValue = $this->normalizeDecimalInput($_POST[$postedName . '_inherited_value'] ?? '');
2455 if ($value === '') {
2456 return '-1';
2457 }
2458 if ($inherited && $inheritedValue !== '' && $value === $inheritedValue) {
2459 return '-1';
2460 }
2461 return $value;
2462 }
2463
2464 private function channelMappingDisplayValues(array $product, array $productChannel): array {
2465 $storedPrice = (float)($productChannel['price_gross'] ?? -1);
2466 $storedShipping = (float)($productChannel['shipping_gross'] ?? -1);
2467 $inheritedPrice = number_format((float)($product['price_gross'] ?? 0), 2, '.', '');
2468 $inheritedShipping = number_format((float)($product['effective_shipping_gross'] ?? $product['shipping_gross'] ?? 0), 2, '.', '');
2469 $priceInherited = $storedPrice < 0;
2470 $shippingInherited = $storedShipping < 0;
2471
2472 return array(
2473 'price_gross' => $priceInherited ? $inheritedPrice : number_format($storedPrice, 2, '.', ''),
2474 'shipping_gross' => $shippingInherited ? $inheritedShipping : number_format($storedShipping, 2, '.', ''),
2475 'price_gross_inherited' => $priceInherited ? '1' : '0',
2476 'price_gross_inherited_value' => $inheritedPrice,
2477 'shipping_gross_inherited' => $shippingInherited ? '1' : '0',
2478 'shipping_gross_inherited_value' => $inheritedShipping,
2479 );
2480 }
2481
2482 private function productChannelMapping(): string {
2483 $this->ensureSeed();
2484 $productId = (int)dbx()->get_modul_var('id', 0, 'int');
2485 $channelKey = trim((string)dbx()->get_modul_var('channel', '', 'parameter'));
2486 if ($productId <= 0 || $channelKey === '') {
2487 return $this->frame('<div class="alert alert-warning m-3">Artikel oder Channel fehlt.</div>', 'Channel-Mapping');
2488 }
2489
2490 $state = $this->repo()->productChannelMapping($productId, $channelKey);
2491 if (!$state) {
2492 return $this->frame('<div class="alert alert-warning m-3">Artikel oder Channel wurde nicht gefunden.</div>', 'Channel-Mapping');
2493 }
2494
2495 $product = (array)$state['product'];
2496 $channel = (array)$state['channel'];
2497 $productChannel = (array)$state['product_channel'];
2498 $mapping = (array)$state['mapping'];
2499 $platform = (string)($channel['platform_type'] ?? 'custom');
2500 $mappingDefaults = $this->channelMappingInheritedDefaults($platform, $channel, $product);
2501 $mapping = $this->mergeMappingDefaults($mappingDefaults, $mapping);
2502 $message = '';
2503
2504 if ($this->posted('save_channel_mapping')) {
2505 $saveProductId = (int)($_POST['product_id'] ?? $productId);
2506 $saveChannelKey = trim((string)($_POST['channel_key_ref'] ?? $channelKey));
2507 if ($saveProductId === $productId && $saveChannelKey === $channelKey) {
2508 $this->repo()->saveProductChannelMapping($productId, $channelKey, array(
2509 'active' => !empty($_POST['active']) ? 1 : 0,
2510 'channel_sku' => (string)($_POST['channel_sku'] ?? ''),
2511 'price_gross' => $this->channelInheritedDecimalValue('price_gross'),
2512 'shipping_gross' => $this->channelInheritedDecimalValue('shipping_gross'),
2513 'external_listing_id' => (string)($_POST['external_listing_id'] ?? ''),
2514 'external_offer_id' => (string)($_POST['external_offer_id'] ?? ''),
2515 'mapping' => $this->removeInheritedMappingDefaults($this->collectProductChannelMapping($platform, $mappingDefaults), $mappingDefaults),
2516 ));
2517 $message = 'Channel-Mapping gespeichert.';
2518 $state = $this->repo()->productChannelMapping($productId, $channelKey) ?: $state;
2519 $productChannel = (array)$state['product_channel'];
2520 $mapping = (array)$state['mapping'];
2521 $mappingDefaults = $this->channelMappingInheritedDefaults($platform, $channel, $product);
2522 $mapping = $this->mergeMappingDefaults($mappingDefaults, $mapping);
2523 }
2524 }
2525
2526 $displayValues = $this->channelMappingDisplayValues($product, $productChannel);
2527 $form = dbx()->get_system_obj('dbxForm');
2528 $form->init('shop-product-channel-mapping-' . $productId . '-' . $channelKey, 'shop-product-channel-mapping');
2529 $form->_dd = 'dbxShop|shopProductChannel';
2530 $form->_fd = 'dbxShop|shop-product-channel';
2531 $form->_data = array_merge($productChannel + array(
2532 'product_id' => $productId,
2533 'channel_key' => $channelKey,
2534 'active' => 1,
2535 'channel_sku' => (string)($product['sku'] ?? ''),
2536 'price_gross' => -1,
2537 'shipping_gross' => -1,
2538 ), array(
2539 'price_gross' => $displayValues['price_gross'],
2540 'shipping_gross' => $displayValues['shipping_gross'],
2541 ));
2542 $form->_rid = (int)($productChannel['id'] ?? 0);
2543 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=product_channel_mapping&id=' . $productId . '&channel=' . rawurlencode($channelKey);
2544 if ((int)dbx()->get_modul_var('dbx_ajax', 0, 'int') === 1) {
2545 $form->_action .= '&dbx_ajax=1';
2546 }
2547 $form->set_activ_id((int)($productChannel['id'] ?? 0));
2548 $form->add_rep('shop_admin_style', $this->shopAdminStyle());
2549 $form->add_rep('bar_class', 'dbx-module-bar');
2550 $form->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
2551 $form->add_rep('bar_actions_class', 'dbx-module-bar-actions');
2552 $form->add_rep('bar_icon', 'bi-sliders2');
2553 $form->add_rep('bar_title', 'Channel-Mapping');
2554 $form->add_rep('bar_subtitle', $this->h((string)($product['sku'] ?? '') . ' - ' . (string)($channel['title'] ?? $channelKey)));
2555 $form->add_rep('bar_actions', $this->helpButton($this->ensureShopProductChannelMappingHelpPage(), 'Hilfe: Channel-Mapping je Artikel', 'btn btn-outline-secondary btn-sm')
2556 . '<button class="btn btn-primary btn-sm" type="submit" title="Speichern"><i class="bi bi-save"></i> Speichern</button>'
2557 . '<a class="btn btn-outline-primary btn-sm dbxConfirm" href="?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $productId . '&export_channel=' . rawurlencode($channelKey) . '&dbx_ajax=1" data-confirm-title="<i class=\'bi bi-broadcast\'></i> Channel exportieren" data-confirm="Artikel jetzt exportieren?" data-confirm-buttons="yesno" title="Exportieren"><i class="bi bi-broadcast"></i></a>');
2558 $form->add_rep('product_id', (string)$productId);
2559 $form->add_rep('channel_key', $this->h($channelKey));
2560 $form->add_rep('price_gross_inherited', $displayValues['price_gross_inherited']);
2561 $form->add_rep('price_gross_inherited_value', $displayValues['price_gross_inherited_value']);
2562 $form->add_rep('shipping_gross_inherited', $displayValues['shipping_gross_inherited']);
2563 $form->add_rep('shipping_gross_inherited_value', $displayValues['shipping_gross_inherited_value']);
2564 $form->add_rep('mapping_message', $message !== '' ? '<div class="alert alert-success mb-3">' . $this->h($message) . '</div>' : '');
2565 $form->add_rep('mapping_intro', $this->h('Hier werden die produktbezogenen Werte fuer diesen Channel gepflegt. Globale API-Zugangsdaten bleiben in der Channel-Verwaltung.'));
2566 $form->add_rep('provider_title', $this->h(($channel['title'] ?? $channelKey) . ' Mapping'));
2567 $form->add_rep('export_status_view', $this->productChannelExportStatusHtml($productChannel));
2568 $form->add_fld('active', label: 'Aktiv');
2569 $form->add_fld('channel_sku', label: 'Channel-SKU', placeholder: (string)($product['sku'] ?? ''));
2570 $form->add_fld('price_gross', label: 'Channel-Preis', placeholder: $displayValues['price_gross']);
2571 $form->add_fld('shipping_gross', label: 'Channel-Versand', placeholder: $displayValues['shipping_gross']);
2572 $form->add_fld('external_listing_id', label: 'Listing-ID');
2573 $form->add_fld('external_offer_id', label: 'Offer-ID');
2574 $form->add_obj('provider_mapping', 'obj-value', $this->providerMappingHtml($platform, $mapping, $channel, $product));
2575 return $form->run();
2576 }
2577
2578 private function productChannelExportStatusHtml(array $productChannel): string {
2579 $status = trim((string)($productChannel['export_status'] ?? ''));
2580 $message = trim((string)($productChannel['export_message'] ?? ''));
2581 $listing = trim((string)($productChannel['external_listing_id'] ?? ''));
2582 $offer = trim((string)($productChannel['external_offer_id'] ?? ''));
2583 $date = trim((string)($productChannel['last_export_date'] ?? ''));
2584 $html = '<div class="d-flex flex-wrap gap-2 align-items-center">';
2585 $html .= '<span class="badge text-bg-' . ($status === 'failed' ? 'danger' : ($status !== '' ? 'info' : 'secondary')) . '">' . $this->h($status !== '' ? $status : 'nicht exportiert') . '</span>';
2586 if ($date !== '') $html .= '<span class="text-muted small">' . $this->h($date) . '</span>';
2587 if ($listing !== '') $html .= '<code>Listing: ' . $this->h($listing) . '</code>';
2588 if ($offer !== '') $html .= '<code>Offer: ' . $this->h($offer) . '</code>';
2589 $html .= '</div>';
2590 if ($message !== '') {
2591 $html .= '<div class="alert alert-secondary py-2 mt-2 mb-0">' . $this->h($message) . '</div>';
2592 }
2593 return $html;
2594 }
2595
2596 private function posted(string $action): bool {
2597 return ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && (string)($_POST['shop_action'] ?? '') === $action;
2598 }
2599
2600 private function cmsEndpoint(string $run1, array $params = array()): string {
2601 $url = '?dbx_modul=dbxContent_admin&dbx_run1=' . rawurlencode($run1);
2602 foreach ($params as $key => $value) {
2603 $url .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
2604 }
2605 return $url;
2606 }
2607
2608 private function shopEndpoint(string $run1, array $params = array()): string {
2609 $url = '?dbx_modul=dbxShop_admin&dbx_run1=' . rawurlencode($run1);
2610 foreach ($params as $key => $value) {
2611 $url .= '&' . rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
2612 }
2613 return $url;
2614 }
2615
2616 private function readJsonPayload(): array {
2617 $raw = (string)file_get_contents('php://input');
2618 $data = $raw !== '' ? json_decode($raw, true) : null;
2619 if (is_array($data)) {
2620 return $data;
2621 }
2622 return $_POST;
2623 }
2624
2625 private function ensureCmsShopMediaFolder(): void {
2626 if (!function_exists('dbx_get_file_dir')) {
2627 return;
2628 }
2629 $dir = rtrim((string)dbx_get_file_dir(), '/\\') . '/media/img/shop';
2630 if (!is_dir($dir)) {
2631 @mkdir($dir, 0775, true);
2632 }
2633 }
2634
2635 private function jsonExit(array $data): string {
2636 if (!headers_sent()) {
2637 header('Content-Type: application/json; charset=utf-8');
2638 }
2639 echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
2640 exit;
2641 }
2642
2643 private function assignMedia(): string {
2644 $payload = $this->readJsonPayload();
2645 $productId = (int)($payload['product_id'] ?? 0);
2646 $groupId = (int)($payload['group_id'] ?? 0);
2647 $mediaId = (int)($payload['media_id'] ?? $payload['id'] ?? 0);
2648 if ($mediaId <= 0 || ($productId <= 0 && $groupId <= 0)) {
2649 return $this->jsonExit(array('ok' => 0, 'msg' => 'Bitte Artikel oder Artikelgruppe und ein Medium auswaehlen.'));
2650 }
2651
2652 $row = $this->repo()->saveMediaImage(
2653 $productId,
2654 $groupId,
2655 $mediaId,
2656 (string)($payload['title'] ?? ''),
2657 (string)($payload['alt'] ?? $payload['title'] ?? ''),
2658 !empty($payload['is_primary']) ? 1 : 0,
2659 (int)($payload['sorter'] ?? 100)
2660 );
2661 if (!$row) {
2662 return $this->jsonExit(array('ok' => 0, 'msg' => 'Bild konnte nicht zugeordnet werden.'));
2663 }
2664 $this->syncShopMediaUsage();
2665 return $this->jsonExit(array(
2666 'ok' => 1,
2667 'image' => $row,
2668 'url' => $this->mediaItemUrl($row, true),
2669 ));
2670 }
2671
2672 private function productTreeMove(): string {
2673 $this->ensureSeed();
2674 $payload = $this->readJsonPayload();
2675 $type = (string)($payload['type'] ?? '');
2676 $targetGroupId = (int)($payload['target_group_id'] ?? 0);
2677
2678 if ($type === 'product') {
2679 $productId = (int)($payload['product_id'] ?? 0);
2680 if ($productId <= 0 || $targetGroupId <= 0) {
2681 return $this->jsonExit(array('ok' => 0, 'msg' => 'Artikel und Zielgruppe sind erforderlich.'));
2682 }
2683 $count = $this->repo()->setProductGroupForProducts(array($productId), $targetGroupId);
2684 if ($count <= 0) {
2685 return $this->jsonExit(array('ok' => 0, 'msg' => 'Artikel konnte nicht verschoben werden.'));
2686 }
2687 return $this->jsonExit(array('ok' => 1, 'msg' => 'Artikelgruppe wurde zugeordnet.'));
2688 }
2689
2690 if ($type === 'group') {
2691 $groupId = (int)($payload['group_id'] ?? 0);
2692 if ($groupId <= 0) {
2693 return $this->jsonExit(array('ok' => 0, 'msg' => 'Artikelgruppe ist erforderlich.'));
2694 }
2695 if (!$this->repo()->moveProductGroupParent($groupId, $targetGroupId)) {
2696 return $this->jsonExit(array('ok' => 0, 'msg' => 'Artikelgruppe konnte nicht verschoben werden. Pruefen Sie, ob dadurch ein Kreis entstehen wuerde.'));
2697 }
2698 return $this->jsonExit(array('ok' => 1, 'msg' => 'Artikelgruppe wurde verschoben.'));
2699 }
2700
2701 return $this->jsonExit(array('ok' => 0, 'msg' => 'Unbekannte Drag-Drop-Aktion.'));
2702 }
2703
2704 private function dashboard(): string {
2705 $stats = $this->repo()->dashboardStats();
2706 return $this->frame($this->tpl()->get_tpl('dbxShop_admin|admin-dashboard', array(
2707 'orders_open' => (string)($stats['orders_open'] ?? 0),
2708 'payments_open' => (string)($stats['payments_open'] ?? 0),
2709 'shipping_open' => (string)($stats['shipping_open'] ?? 0),
2710 'withdrawals_open' => (string)($stats['withdrawals_open'] ?? 0),
2711 'stock_low' => (string)($stats['stock_low'] ?? 0),
2712 'products_active' => (string)($stats['products_active'] ?? 0),
2713 'product_url' => '?dbx_modul=dbxShop_admin&dbx_run1=products',
2714 'order_url' => '?dbx_modul=dbxShop_admin&dbx_run1=orders',
2715 'group_url' => '?dbx_modul=dbxShop_admin&dbx_run1=groups',
2716 'attribute_url' => '?dbx_modul=dbxShop_admin&dbx_run1=attributes',
2717 'shipping_group_url' => '?dbx_modul=dbxShop_admin&dbx_run1=shipping_groups',
2718 'channel_group_url' => '?dbx_modul=dbxShop_admin&dbx_run1=channel_groups',
2719 'channel_url' => '?dbx_modul=dbxShop_admin&dbx_run1=channels',
2720 'media_url' => '?dbx_modul=dbxShop_admin&dbx_run1=media',
2721 'legal_url' => '?dbx_modul=dbxShop_admin&dbx_run1=legal',
2722 'return_url' => '?dbx_modul=dbxShop_admin&dbx_run1=returns',
2723 )));
2724 }
2725
2726 private function placeholder(string $title, string $text): string {
2727 return $this->frame(
2728 '<div class="alert alert-info m-3"><strong>' . $this->h($title) . '</strong><br>' . $this->h($text) . '</div>',
2729 $title
2730 );
2731 }
2732
2733 private function shopLegalCmsPage(string $key, string $title, string $intro, string $shopRun): string {
2734 $service = dbx()->get_include_obj('dbxShopService', 'dbxShop');
2735 $pages = is_object($service) && method_exists($service, 'ensureShopLegalPages')
2736 ? $service->ensureShopLegalPages()
2737 : array();
2738 $cid = (int)($pages[$key] ?? 0);
2739 if ($cid <= 0) {
2740 return $this->placeholder($title, 'Die CMS-Seite konnte nicht angelegt oder gefunden werden.');
2741 }
2742
2743 $row = $this->db()->select1($this->contentDd(), $cid, 'id,title,permalink,content,group_read,template,activ', 0);
2744 if (!is_array($row) || (int)($row['id'] ?? 0) <= 0) {
2745 return $this->placeholder($title, 'Die CMS-Seite konnte nicht geladen werden.');
2746 }
2747
2748 $editUrl = '?dbx_modul=dbxContent_admin&dbx_run1=cms&cid=' . $cid . '&dbx_ajax=1';
2749 $cmsViewUrl = '?dbx_modul=dbxContent&dbx_run1=content&cid=' . $cid . '&dbx_ajax=1';
2750 $shopViewUrl = '?dbx_modul=dbxShop&dbx_run1=' . rawurlencode($shopRun) . '&dbx_ajax=1';
2751 $permalink = (string)($row['permalink'] ?? '');
2752
2753 $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%')
2754 . $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%')
2755 . $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%');
2756
2757 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
2758 $preview = is_object($renderer) && method_exists($renderer, 'renderStatic')
2759 ? (string)$renderer->renderStatic($cid, array('template' => 'c-body1-footer', 'skip_hits' => true))
2760 : trim((string)($row['content'] ?? ''));
2761 if (trim($preview) === '') {
2762 $preview = '<div class="alert alert-warning">Die CMS-Seite ist leer.</div>';
2763 }
2764
2765 $meta = '<dl class="dbx-shop-admin-cms-meta">'
2766 . '<dt>CMS-ID</dt><dd>' . $cid . '</dd>'
2767 . '<dt>Permalink</dt><dd><code>' . $this->h($permalink) . '</code></dd>'
2768 . '<dt>Status</dt><dd>' . ((int)($row['activ'] ?? 0) === 1 ? 'Aktiv' : 'Inaktiv') . '</dd>'
2769 . '<dt>Leserechte</dt><dd><code>' . $this->h((string)($row['group_read'] ?? '')) . '</code></dd>'
2770 . '<dt>Template</dt><dd><code>' . $this->h((string)($row['template'] ?? '')) . '</code></dd>'
2771 . '</dl>';
2772
2773 $html = '<section class="dbx-shop-admin-cms-page">'
2774 . '<div class="alert alert-info"><strong>' . $this->h($title) . '</strong><br>' . $this->h($intro) . '</div>'
2775 . $meta
2776 . '<div class="dbx-shop-admin-cms-preview">' . $preview . '</div>'
2777 . '</section>';
2778
2779 return $this->frame($html, $title, $actions);
2780 }
2781
2782 private function settingsBool(array $cfg, string $key, bool $default = false): bool {
2783 if (!array_key_exists($key, $cfg)) {
2784 return $default;
2785 }
2786 $value = $cfg[$key];
2787 if (is_bool($value)) return $value;
2788 return in_array(strtolower(trim((string)$value)), array('1', 'true', 'yes', 'on'), true);
2789 }
2790
2791 private function saveSettings(): void {
2792 $cfg = $this->shopConfig();
2793 $cfg['enabled'] = !empty($_POST['enabled']);
2794 $cfg['activ'] = $cfg['enabled'] ? '1' : '0';
2795 $cfg['default_channel'] = trim((string)($_POST['default_channel'] ?? 'shop')) ?: 'shop';
2796 $cfg['default_currency'] = strtoupper(substr(preg_replace('~[^A-Z]~i', '', (string)($_POST['default_currency'] ?? 'EUR')) ?: 'EUR', 0, 3));
2797 $cfg['price_display'] = in_array((string)($_POST['price_display'] ?? 'gross'), array('gross', 'net'), true) ? (string)$_POST['price_display'] : 'gross';
2798 $cfg['default_tax_class'] = in_array((string)($_POST['default_tax_class'] ?? 'mwst1'), array('mwst1', 'mwst2', 'mwst3'), true) ? (string)$_POST['default_tax_class'] : 'mwst1';
2799 $cfg['tax_display_enabled'] = (string)($_POST['tax_display_enabled'] ?? '1') !== '0';
2800
2801 $rates = array();
2802 foreach (array('mwst1', 'mwst2', 'mwst3') as $key) {
2803 $title = trim((string)($_POST['tax_title_' . $key] ?? $key));
2804 $rate = str_replace(',', '.', trim((string)($_POST['tax_rate_' . $key] ?? '0')));
2805 $rates[$key] = array(
2806 'title' => $title !== '' ? $title : $key,
2807 'rate' => number_format((float)$rate, 2, '.', ''),
2808 );
2809 }
2810 $cfg['tax_rates'] = $rates;
2811
2812 foreach (array(
2813 'b2b_mode',
2814 'stock_enabled',
2815 'channels_enabled',
2816 'checkout_guest_allowed',
2817 'legal_snapshot_enabled',
2818 'withdrawal_button_enabled',
2819 'mail_customer_enabled',
2820 'mail_admin_enabled',
2821 'payment_bank_transfer_enabled',
2822 'payment_invoice_enabled',
2823 'payment_paypal_enabled',
2824 'payment_amazon_pay_enabled',
2825 'delivery_digital_download_enabled',
2826 'delivery_flat_shipping_enabled',
2827 ) as $key) {
2828 $cfg[$key] = !empty($_POST[$key]);
2829 }
2830
2831 $cfg['payment_bank_transfer_account_owner'] = trim((string)($_POST['payment_bank_transfer_account_owner'] ?? ''));
2832 $cfg['payment_bank_transfer_iban'] = trim((string)($_POST['payment_bank_transfer_iban'] ?? ''));
2833 $cfg['payment_bank_transfer_bic'] = trim((string)($_POST['payment_bank_transfer_bic'] ?? ''));
2834 $cfg['payment_bank_transfer_bank_name'] = trim((string)($_POST['payment_bank_transfer_bank_name'] ?? ''));
2835 $cfg['payment_bank_transfer_instructions'] = trim((string)($_POST['payment_bank_transfer_instructions'] ?? ''));
2836 $cfg['payment_invoice_instructions'] = trim((string)($_POST['payment_invoice_instructions'] ?? ''));
2837 $cfg['payment_paypal_mode'] = (string)($_POST['payment_paypal_mode'] ?? 'sandbox') === 'live' ? 'live' : 'sandbox';
2838 $cfg['payment_paypal_client_id'] = trim((string)($_POST['payment_paypal_client_id'] ?? ''));
2839 $cfg['payment_paypal_client_secret'] = trim((string)($_POST['payment_paypal_client_secret'] ?? ''));
2840 $cfg['payment_paypal_brand_name'] = trim((string)($_POST['payment_paypal_brand_name'] ?? 'dbXapp')) ?: 'dbXapp';
2841 $cfg['payment_paypal_currency'] = $cfg['default_currency'];
2842 $cfg['payment_amazon_pay_mode'] = (string)($_POST['payment_amazon_pay_mode'] ?? 'sandbox') === 'live' ? 'live' : 'sandbox';
2843 $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';
2844 $cfg['payment_amazon_pay_merchant_id'] = trim((string)($_POST['payment_amazon_pay_merchant_id'] ?? ''));
2845 $cfg['payment_amazon_pay_store_id'] = trim((string)($_POST['payment_amazon_pay_store_id'] ?? ''));
2846 $cfg['payment_amazon_pay_public_key_id'] = trim((string)($_POST['payment_amazon_pay_public_key_id'] ?? ''));
2847 $cfg['payment_amazon_pay_private_key'] = trim((string)($_POST['payment_amazon_pay_private_key'] ?? ''));
2848 $cfg['payment_amazon_pay_sandbox_simulation_code'] = trim((string)($_POST['payment_amazon_pay_sandbox_simulation_code'] ?? ''));
2849 $cfg['mail_from'] = trim((string)($_POST['mail_from'] ?? ''));
2850 $cfg['mail_admin_to'] = trim((string)($_POST['mail_admin_to'] ?? ''));
2851
2852 $flatShipping = str_replace(',', '.', trim((string)($_POST['delivery_flat_shipping_gross_price'] ?? '0')));
2853 $cfg['delivery_flat_shipping_gross_price'] = number_format((float)$flatShipping, 2, '.', '');
2854 $cfg['media_usage_slot'] = preg_replace('~[^a-z0-9_-]+~i', '', (string)($_POST['media_usage_slot'] ?? 'shop')) ?: 'shop';
2855
2856 dbx()->set_config('dbxShop', $cfg);
2857 }
2858
2859 private function settingsFormData(array $cfg): array {
2860 $rates = $this->taxRatesConfig();
2861 $data = $cfg;
2862 foreach (array('mwst1', 'mwst2', 'mwst3') as $key) {
2863 $rate = is_array($rates[$key] ?? null) ? $rates[$key] : array();
2864 $data['tax_title_' . $key] = (string)($rate['title'] ?? $key);
2865 $data['tax_rate_' . $key] = (string)($rate['rate'] ?? '0');
2866 }
2867
2868 foreach (array(
2869 'enabled',
2870 'b2b_mode',
2871 'stock_enabled',
2872 'channels_enabled',
2873 'checkout_guest_allowed',
2874 'legal_snapshot_enabled',
2875 'withdrawal_button_enabled',
2876 'mail_customer_enabled',
2877 'mail_admin_enabled',
2878 'payment_bank_transfer_enabled',
2879 'payment_invoice_enabled',
2880 'payment_paypal_enabled',
2881 'payment_amazon_pay_enabled',
2882 'delivery_digital_download_enabled',
2883 'delivery_flat_shipping_enabled',
2884 ) as $key) {
2885 $data[$key] = $this->settingsBool($cfg, $key, in_array($key, array(
2886 'enabled',
2887 'checkout_guest_allowed',
2888 'legal_snapshot_enabled',
2889 'withdrawal_button_enabled',
2890 'mail_customer_enabled',
2891 'mail_admin_enabled',
2892 'payment_bank_transfer_enabled',
2893 'delivery_digital_download_enabled',
2894 'delivery_flat_shipping_enabled',
2895 ), true)) ? 1 : 0;
2896 }
2897
2898 $data['default_channel'] = (string)($cfg['default_channel'] ?? 'shop');
2899 $data['channels_enabled'] = array_key_exists('channels_enabled', $cfg) ? (int)((bool)$cfg['channels_enabled']) : 1;
2900 $data['default_currency'] = (string)($cfg['default_currency'] ?? 'EUR');
2901 $data['price_display'] = (string)($cfg['price_display'] ?? 'gross');
2902 $data['default_tax_class'] = (string)($cfg['default_tax_class'] ?? 'mwst1');
2903 $data['tax_display_enabled'] = $this->settingsBool($cfg, 'tax_display_enabled', true) ? 1 : 0;
2904 $data['payment_paypal_mode'] = (string)($cfg['payment_paypal_mode'] ?? 'sandbox');
2905 $data['payment_paypal_brand_name'] = (string)($cfg['payment_paypal_brand_name'] ?? 'dbXapp');
2906 $data['payment_paypal_client_id'] = (string)($cfg['payment_paypal_client_id'] ?? '');
2907 $data['payment_paypal_client_secret'] = (string)($cfg['payment_paypal_client_secret'] ?? '');
2908 $data['payment_bank_transfer_account_owner'] = (string)($cfg['payment_bank_transfer_account_owner'] ?? '');
2909 $data['payment_bank_transfer_iban'] = (string)($cfg['payment_bank_transfer_iban'] ?? '');
2910 $data['payment_bank_transfer_bic'] = (string)($cfg['payment_bank_transfer_bic'] ?? '');
2911 $data['payment_bank_transfer_bank_name'] = (string)($cfg['payment_bank_transfer_bank_name'] ?? '');
2912 $data['payment_bank_transfer_instructions'] = (string)($cfg['payment_bank_transfer_instructions'] ?? 'Bitte ueberweisen Sie den Rechnungsbetrag unter Angabe der Bestellnummer.');
2913 $data['payment_invoice_instructions'] = (string)($cfg['payment_invoice_instructions'] ?? 'Sie erhalten eine Rechnung. Bitte zahlen Sie innerhalb der angegebenen Frist.');
2914 $data['payment_amazon_pay_mode'] = (string)($cfg['payment_amazon_pay_mode'] ?? 'sandbox');
2915 $data['payment_amazon_pay_region'] = (string)($cfg['payment_amazon_pay_region'] ?? 'EU');
2916 $data['payment_amazon_pay_merchant_id'] = (string)($cfg['payment_amazon_pay_merchant_id'] ?? '');
2917 $data['payment_amazon_pay_store_id'] = (string)($cfg['payment_amazon_pay_store_id'] ?? '');
2918 $data['payment_amazon_pay_public_key_id'] = (string)($cfg['payment_amazon_pay_public_key_id'] ?? '');
2919 $data['payment_amazon_pay_private_key'] = (string)($cfg['payment_amazon_pay_private_key'] ?? '');
2920 $data['payment_amazon_pay_sandbox_simulation_code'] = (string)($cfg['payment_amazon_pay_sandbox_simulation_code'] ?? '');
2921 $data['mail_from'] = (string)($cfg['mail_from'] ?? '');
2922 $data['mail_admin_to'] = (string)($cfg['mail_admin_to'] ?? '');
2923 $data['delivery_flat_shipping_gross_price'] = (string)($cfg['delivery_flat_shipping_gross_price'] ?? '5.90');
2924 $data['media_usage_slot'] = (string)($cfg['media_usage_slot'] ?? 'shop');
2925
2926 return $data;
2927 }
2928
2929 private function settingsChannelsStatusHtml(array $cfg): string {
2930 $channelsEnabled = $this->settingsBool($cfg, 'channels_enabled', true);
2931 $channels = $this->repo()->channels();
2932 $external = array();
2933 foreach ($channels as $channel) {
2934 $key = strtolower(trim((string)($channel['channel_key'] ?? '')));
2935 if ($key === '' || $key === 'shop') {
2936 continue;
2937 }
2938 if ((int)($channel['active'] ?? 0) !== 1) {
2939 continue;
2940 }
2941 $external[] = $channel;
2942 }
2943
2944 $html = '<div class="dbx-shop-settings-channel-status">';
2945 $html .= '<div class="dbx-shop-settings-channel-head">';
2946 $html .= '<div><strong>Externe Channels</strong><span>' . ($channelsEnabled ? 'global aktiv' : 'global deaktiviert') . '</span></div>';
2947 $html .= $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=channels&dbx_ajax=1', 'Channels bearbeiten', '<i class="bi bi-broadcast"></i> Channels', 'btn btn-outline-primary btn-sm', '92%', '88%');
2948 $html .= '</div>';
2949
2950 if (!$channelsEnabled) {
2951 $html .= '<div class="alert alert-warning py-2 mb-0">Channels sind global ausgeschaltet. Externe Channels werden dadurch nicht aktiv genutzt.</div>';
2952 }
2953
2954 if ($external === array()) {
2955 $html .= '<div class="dbx-shop-settings-channel-empty">Keine aktiven externen Channels eingerichtet.</div>';
2956 return $html . '</div>';
2957 }
2958
2959 $html .= '<div class="dbx-shop-settings-channel-grid">';
2960 $html .= '<div class="dbx-shop-settings-channel-grid-head"><span>Channel</span><span>Plattform</span><span>Verbindung</span><span>Export</span><span>Import</span><span>Test</span></div>';
2961 foreach ($external as $channel) {
2962 $test = trim((string)($channel['test_status'] ?? ''));
2963 $testClass = $test === 'ok' ? 'success' : ($test !== '' ? 'warning' : 'secondary');
2964 $html .= '<div class="dbx-shop-settings-channel-grid-row">';
2965 $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>';
2966 $html .= '<span>' . $this->h((string)($channel['platform_type'] ?? '')) . '</span>';
2967 $html .= '<span>' . $this->h((string)($channel['connection_mode'] ?? '')) . '</span>';
2968 $html .= ((int)($channel['export_enabled'] ?? 0) === 1 ? '<span class="badge text-bg-success">Export</span>' : '<span class="badge text-bg-secondary">Export aus</span>');
2969 $html .= ((int)($channel['order_import_enabled'] ?? 0) === 1 ? '<span class="badge text-bg-primary">Import</span>' : '<span class="badge text-bg-secondary">Import aus</span>');
2970 $html .= '<span class="badge text-bg-' . $testClass . '">' . $this->h($test !== '' ? $test : 'nicht getestet') . '</span>';
2971 $html .= '</div>';
2972 }
2973 $html .= '</div>';
2974 return $html . '</div>';
2975 }
2976
2977 private function settings(): string {
2978 $helpId = $this->ensureShopSettingsHelpPage();
2979 $helpButton = $helpId > 0
2980 ? $this->openWinButton('?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId . '&dbx_ajax=1', 'Hilfe: Shop-Einstellungen', '<i class="bi bi-question-circle"></i><span class="visually-hidden"> Hilfe</span>', 'btn btn-outline-secondary btn-sm me-1', '72%', '82%')
2981 : '';
2982 $form = dbx()->get_system_obj('dbxForm');
2983 $form->init('shop-settings-form', 'shop-settings-form');
2984 $form->_fd = 'dbxShop_admin|shop-settings';
2985 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=settings';
2986 $form->_data = $this->settingsFormData($this->shopConfig());
2987 $form->add_rep('shop_admin_style', $this->shopAdminStyle());
2988 $form->add_rep('form_class', 'dbx-shop-settings-dbXForm');
2989 $form->add_rep('bar_title', 'Einstellungen');
2990 $form->add_rep('bar_icon', 'bi-sliders');
2991 $form->add_rep('bar_subtitle', 'MwSt., Checkout, Zahlungsarten, Versand und Medien');
2992 $form->add_rep('bar_class', 'dbx-module-bar');
2993 $form->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
2994 $form->add_rep('bar_title_pre', '');
2995 $form->add_rep('bar_title_heading_attrs', '');
2996 $form->add_rep('bar_middle', '');
2997 $form->add_rep('bar_actions_class', 'dbx-module-bar-actions');
2998 $form->add_rep('bar_extra', '');
2999 $form->add_obj('channels_status', 'obj-value', $this->settingsChannelsStatusHtml($this->shopConfig()));
3000 $paymentTestButton = $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=payment_test&dbx_ajax=1', 'Payment testen', '<i class="bi bi-plug"></i><span class="visually-hidden"> Payment testen</span>', 'btn btn-outline-primary btn-sm me-1', '64%', '58%');
3001 $form->add_rep('bar_actions', $paymentTestButton . '<button class="btn btn-primary btn-sm" type="submit" name="shop_action" value="save_settings" title="Speichern"><i class="bi bi-save"></i><span class="visually-hidden"> Speichern</span></button>' . $helpButton);
3002 $form->_msg_info = '';
3003 $form->_msg_success = 'Einstellungen gespeichert.';
3004 $form->_msg_error = 'Bitte Eingaben pruefen.';
3005 $form->add_flds();
3006
3007 if ($form->submit()) {
3008 if (!$form->errors() && !$form->warnings()) {
3009 $this->saveSettings();
3010 $form->_data = $this->settingsFormData($this->shopConfig());
3011 $form->add_obj('channels_status', 'obj-value', $this->settingsChannelsStatusHtml($this->shopConfig()));
3012 }
3013 }
3014
3015 return $form->run();
3016 }
3017
3018 private function paymentTest(): string {
3019 $paypal = dbx()->get_include_obj('dbxShopPayPal', 'dbxShop');
3020 $amazonPay = dbx()->get_include_obj('dbxShopAmazonPay', 'dbxShop');
3021 $paypalResult = is_object($paypal) && method_exists($paypal, 'testConnection')
3022 ? $paypal->testConnection()
3023 : array('ok' => false, 'mode' => '', 'message' => 'PayPal-Connector konnte nicht geladen werden.');
3024 $amazonResult = is_object($amazonPay) && method_exists($amazonPay, 'testConnection')
3025 ? $amazonPay->testConnection()
3026 : array('ok' => false, 'mode' => '', 'region' => '', 'message' => 'Amazon-Pay-Connector konnte nicht geladen werden.');
3027
3028 $card = function(string $title, string $icon, array $result): string {
3029 $ok = !empty($result['ok']);
3030 $badge = $ok
3031 ? '<span class="badge text-bg-success">OK</span>'
3032 : '<span class="badge text-bg-warning">Pruefen</span>';
3033 $meta = array();
3034 if (trim((string)($result['mode'] ?? '')) !== '') {
3035 $meta[] = 'Modus: ' . $this->h((string)$result['mode']);
3036 }
3037 if (trim((string)($result['region'] ?? '')) !== '') {
3038 $meta[] = 'Region: ' . $this->h((string)$result['region']);
3039 }
3040 return '<article class="dbx-shop-payment-test-card">'
3041 . '<header><h4><i class="bi ' . $this->h($icon) . '"></i> ' . $this->h($title) . '</h4>' . $badge . '</header>'
3042 . ($meta !== array() ? '<p class="dbx-shop-payment-test-meta">' . implode(' · ', $meta) . '</p>' : '')
3043 . '<p>' . $this->h((string)($result['message'] ?? 'Keine Rueckmeldung.')) . '</p>'
3044 . '</article>';
3045 };
3046
3047 $body = '<section class="dbx-shop-payment-test">'
3048 . '<div class="alert alert-info mb-3"><strong>Payment-Test</strong><br>PayPal prueft die OAuth-Verbindung gegen Sandbox oder Live. Amazon Pay prueft lokal die Zugangsdaten und die RSA-PSS-Signatur; ein echter API-Checkout wird erst mit einer echten Amazon-Pay-Session ausgefuehrt.</div>'
3049 . $card('PayPal', 'bi-paypal', $paypalResult)
3050 . $card('Amazon Pay', 'bi-amazon', $amazonResult)
3051 . '<div class="dbx-shop-payment-test-links">'
3052 . '<a href="https://developer.paypal.com/dashboard/applications/" target="_blank" rel="noopener">PayPal Apps & Credentials</a>'
3053 . '<a href="https://developer.paypal.com/tools/sandbox/" target="_blank" rel="noopener">PayPal Sandbox</a>'
3054 . '<a href="https://sellercentral.amazon.de/" target="_blank" rel="noopener">Amazon Seller Central</a>'
3055 . '<a href="https://developer.amazon.com/docs/amazon-pay-api-v2/introduction.html" target="_blank" rel="noopener">Amazon Pay API v2</a>'
3056 . '</div>'
3057 . '</section>';
3058 return $this->frame($body, 'Payment testen');
3059 }
3060
3061 private function install(): string {
3062 $this->repo()->seedDemoProducts();
3063 return $this->placeholder(
3064 'Shop-Installation ausgefuehrt',
3065 'dbxShop.db3 wurde angelegt bzw. aktualisiert. Deutsche Testartikel, Gruppen und Channel-Zuordnungen sind vorhanden.'
3066 );
3067 }
3068
3069 private function products(): string {
3070 $this->ensureSeed();
3071 $this->syncShopMediaUsage();
3072 $report = dbx()->get_system_obj('dbxReport');
3073 $report->init('shop-products-report');
3074 $report->_action = '?dbx_modul=dbxShop_admin&dbx_run1=products';
3075 $report->_mode = 'table';
3076 $report->_pages = true;
3077 $report->_multi_page_select = 1;
3078 $report->_create_row_select = true;
3079 $report->_create_row_delete = true;
3080 $report->_create_row_edit = true;
3081 $report->_create_row_show = true;
3082 $report->_create_sel_flds = true;
3083 $report->_but_pagination = 7;
3084 $report->_fld_id = 'id';
3085 $report->_msg_confirm_delete = 'Diesen Artikel wirklich loeschen?';
3086 $report->_msg_info = 'Produkte suchen, pruefen und ueber Fenster bearbeiten.';
3087 $report->add_rep('bar_title', 'Produkte');
3088 $report->add_rep('bar_icon', 'bi-bag-check');
3089 $report->add_rep('bar_subtitle', 'Produkte, Bestellungen, Rechtstexte und Widerrufe');
3090 $report->add_rep('bar_class', 'dbx-module-bar');
3091 $report->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
3092 $report->add_rep('bar_actions_class', 'dbx-module-bar-actions');
3093 $report->add_rep('bar_title_pre', $this->productTreeToggleButton());
3094 $report->add_rep('bar_title_heading_attrs', '');
3095 $report->add_rep('bar_middle', '');
3096 $report->add_rep('bar_extra', '');
3097 $report->add_rep('bar_actions', $this->productShellActions());
3098 $report->add_rep('shop_admin_style', $this->shopAdminStyle());
3099 $report->add_rep('report_form_class', 'dbx-shop-products-form is-shop-tree-collapsed');
3100 $report->add_rep('report_form_attrs', 'data-dbx="lib=shopAdmin" data-shop-tree-shell');
3101 $channelsEnabled = $this->channelsEnabled();
3102 $columnClasses = array(
3103 'image_view' => 'dbx-shop-col-image',
3104 'article_view' => 'dbx-shop-col-article',
3105 'groups_view' => 'dbx-shop-col-groups',
3106 'attributes_view' => 'dbx-shop-col-attributes',
3107 'shipping_groups_view' => 'dbx-shop-col-shipping-groups',
3108 'price_view' => 'dbx-shop-col-money',
3109 'tax_view' => 'dbx-shop-col-tax',
3110 'shipping_view' => 'dbx-shop-col-money',
3111 'status_view' => 'dbx-shop-col-status',
3112 );
3113 if ($channelsEnabled) {
3114 $columnClasses['channel_groups_view'] = 'dbx-shop-col-channel-groups';
3115 $columnClasses['channels_view'] = 'dbx-shop-col-channels';
3116 }
3117 foreach ($columnClasses as $field => $class) {
3118 $report->set_class_haeder($field, $class);
3119 $report->_class_body[$field] = $class;
3120 }
3121 $report->set_callback_owner($this);
3122 $report->set_callback('next_record', 'product_report_next_record');
3123 $report->set_callback('row_action_data', 'product_report_row_action_data');
3124 $report->add_rep('report_products_actions', $this->productReportActionControls($report->_action));
3125 $report->create_selection_fields('dbxShop_admin|rpt-products-selection');
3126 $this->handleProductReportAction($report);
3127
3128 $query = trim((string)$report->get_fld_val('dbx_rwhere', '', 'parameter|max=100'));
3129 $requestedRowsPerPage = (int)$report->get_fld_val('dbx_rrows', 30, 'int');
3130 $rowsPerPage = $requestedRowsPerPage === 0 ? 0 : max(10, min(100, $requestedRowsPerPage));
3131 $position = $rowsPerPage === 0 ? 0 : max(0, (int)$report->get_fld_val('dbx_rpos', 0, 'int'));
3132 $sort = (string)$report->get_fld_val('dbx_rsort', 'sorter', 'parameter');
3133 $direction = strtoupper((string)$report->get_fld_val('dbx_rdesc', 'ASC', 'parameter')) === 'DESC' ? 'DESC' : 'ASC';
3134 $selectedOnly = (int)$report->get_fld_val('dbx_rselect', 0, 'int') === 1;
3135 if (!in_array($sort, array('sorter', 'sku', 'title', 'price_gross', 'effective_tax_rate', 'effective_shipping_gross', 'active'), true)) {
3136 $sort = 'sorter';
3137 }
3138
3139 $allProducts = $this->repo()->products(false);
3140 $matchedProducts = array();
3141 $selectedIds = $selectedOnly ? $report->get_multi_selects() : array();
3142 foreach ($allProducts as $product) {
3143 if ($selectedOnly && !isset($selectedIds[(string)(int)($product['id'] ?? 0)])) {
3144 continue;
3145 }
3146 $score = $this->productSearchScore($product, $query);
3147 if ($score <= 0) {
3148 continue;
3149 }
3150 $product['_search_score'] = $score;
3151 $matchedProducts[] = $product;
3152 }
3153 $matchedProducts = $this->sortProductsForReport($matchedProducts, $query, $sort, $direction);
3154 $filteredCount = count($matchedProducts);
3155 if ($rowsPerPage > 0 && $position >= $filteredCount && $filteredCount > 0) {
3156 $position = max(0, (int)(floor(($filteredCount - 1) / $rowsPerPage) * $rowsPerPage));
3157 }
3158 $visibleProducts = $rowsPerPage === 0
3159 ? $matchedProducts
3160 : array_slice($matchedProducts, $position, $rowsPerPage);
3161
3162 $report->_rflds = array(
3163 'image_view' => 'Bild',
3164 'article_view' => 'Artikel',
3165 'groups_view' => 'Artikelgruppen',
3166 'attributes_view' => 'Attribute',
3167 'shipping_groups_view' => 'Versandgruppen',
3168 'price_view' => 'Preis',
3169 'tax_view' => 'MwSt.',
3170 'shipping_view' => 'Versand',
3171 'status_view' => 'Status',
3172 );
3173 if ($channelsEnabled) {
3174 $report->_rflds = array_slice($report->_rflds, 0, 5, true)
3175 + array('channel_groups_view' => 'Channel-Gruppen', 'channels_view' => 'Channels')
3176 + array_slice($report->_rflds, 5, null, true);
3177 }
3178 $report->_rpt_format = array(
3179 'image_view' => 'html',
3180 'article_view' => 'html',
3181 'groups_view' => 'html',
3182 'attributes_view' => 'html',
3183 'shipping_groups_view' => 'html',
3184 'price_view' => 'html',
3185 'tax_view' => 'html',
3186 'shipping_view' => 'html',
3187 'status_view' => 'html',
3188 );
3189 if ($channelsEnabled) {
3190 $report->_rpt_format['channel_groups_view'] = 'html';
3191 $report->_rpt_format['channels_view'] = 'html';
3192 }
3193 $report->_rrows = $rowsPerPage;
3194 $report->_rpos = $position;
3195 $report->_count_all = count($allProducts);
3196 $report->_rcount = $filteredCount;
3197 $report->_rdata = $visibleProducts;
3198
3199 $report->add_rep('product_tree_panel', $this->productTreePanel($allProducts));
3200 $content = $report->run();
3201 if ($filteredCount === 0) {
3202 $content .= '<div class="alert alert-info mx-3">Keine Produkte fuer diese Suche gefunden.</div>';
3203 }
3204
3205 return $content;
3206 }
3207
3208 private function productEdit(): string {
3209 $this->ensureSeed();
3210 $id = (int)dbx()->get_modul_var('id', 0, 'int');
3211 $isNew = $id <= 0;
3212 $exportNotice = '';
3213 $exportOk = false;
3214 $removeImageId = (int)dbx()->get_modul_var('remove_image', 0, 'int');
3215 if (!$isNew && $removeImageId > 0) {
3216 $this->repo()->removeProductImageAssociation($removeImageId, $id);
3217 $this->syncShopMediaUsage();
3218 }
3219 $exportChannel = trim((string)dbx()->get_modul_var('export_channel', '', 'parameter'));
3220 if (!$isNew && $exportChannel !== '') {
3221 $result = $this->repo()->exportProductToChannel($id, $exportChannel);
3222 $exportOk = !empty($result['ok']);
3223 $exportNotice = (string)($result['message'] ?? '');
3224 }
3225 $data = $isNew ? $this->applyProductPreset($this->newProductDefaults()) : $this->repo()->productById($id);
3226
3227 if (!$isNew && !is_array($data)) {
3228 return $this->frame('<div class="alert alert-warning m-3">Artikel nicht gefunden.</div>', 'Artikel bearbeiten', $this->productBarActions());
3229 }
3230
3231 $form = dbx()->get_system_obj('dbxForm');
3232 $form->init('shop-product-form', 'shop-product-form');
3233 $form->_dd = 'dbxShop|shopProduct';
3234 $form->_fd = 'dbxShop|shop-product';
3235 $form->_data = $data;
3236 $form->_rid = $isNew ? 0 : $id;
3237 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . ($isNew ? 0 : $id);
3238 if ((int)dbx()->get_modul_var('dbx_ajax', 0, 'int') === 1) {
3239 $form->_action .= '&dbx_ajax=1';
3240 }
3241 $form->set_activ_id($isNew ? 0 : $id);
3242 $form->add_rep('bar_title', $isNew ? 'Neuer Artikel' : 'Artikel bearbeiten');
3243 $form->add_rep('bar_icon', 'bi-bag-check');
3244 $form->add_rep('bar_subtitle', $isNew ? 'Stammdaten anlegen' : trim((string)($data['sku'] ?? '') . ' - ' . (string)($data['title'] ?? '')));
3245 $form->add_rep('bar_class', 'dbx-module-bar');
3246 $form->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
3247 $form->add_rep('bar_title_pre', '');
3248 $form->add_rep('bar_title_heading_attrs', '');
3249 $form->add_rep('bar_middle', '');
3250 $form->add_rep('bar_actions_class', 'dbx-module-bar-actions');
3251 $form->add_rep('bar_actions', $this->productFormActions($id));
3252 $form->add_rep('bar_extra', '');
3253 $form->add_rep('shop_admin_style', $this->shopAdminStyle());
3254 $form->_msg_info = 'Artikel-Stammdaten bearbeiten. Gruppen, Attribute und Bilder werden ueber die jeweiligen Shop-Admin-Funktionen gepflegt.';
3255 if ($exportNotice !== '') {
3256 if ($exportOk) {
3257 $form->_msg_success = $exportNotice;
3258 } else {
3259 $form->_msg_error = $exportNotice;
3260 }
3261 }
3262 $form->add_flds();
3263 $form->add_fld('product_group_id', tpl: 'select-single-label', label: 'Artikelgruppe', options: $this->productGroupOptions(0, false), rules: 'int');
3264
3265 if ($form->submit()) {
3266 if (!$form->errors()) {
3267 $ok = $form->save_post('dbxShop|shopProduct', $isNew ? 'new' : $id, $this->productFormDefaults($id));
3268 if ($ok) {
3269 $savedId = (int)$form->_rid;
3270 $groupId = (int)($_POST['product_group_id'] ?? 0);
3271 if ($savedId > 0 && $groupId > 0) {
3272 $this->repo()->setProductGroupForProducts(array($savedId), $groupId);
3273 }
3274 if ($savedId > 0 && isset($_POST['product_channel_editor'])) {
3275 $this->repo()->saveProductChannelOverrides($savedId, (array)($_POST['product_channels'] ?? array()));
3276 }
3277 $form->_msg_success = 'Artikel gespeichert.';
3278 if ($savedId > 0) {
3279 $id = $savedId;
3280 $isNew = false;
3281 $data = $this->repo()->productById($savedId) ?: $form->_data;
3282 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=product_edit&id=' . $savedId;
3283 if ((int)dbx()->get_modul_var('dbx_ajax', 0, 'int') === 1) {
3284 $form->_action .= '&dbx_ajax=1';
3285 }
3286 }
3287 } else {
3288 $form->_msg_error = 'Artikel konnte nicht gespeichert werden. Bitte pruefen Sie insbesondere SKU und URL-Name.';
3289 }
3290 } else {
3291 $form->_msg_error = 'Bitte Eingaben pruefen.';
3292 }
3293 }
3294
3295 $form->add_obj('product_images', 'obj-value', $this->productImagesPanel(is_array($data) ? $data : array(), $isNew));
3296 $form->add_obj('product_channels', 'obj-value', $this->productChannelsPanel(is_array($data) ? $data : array(), $isNew));
3297
3298 return $form->run();
3299 }
3300
3301 private function productsHelp(): string {
3302 $helpId = $this->ensureShopProductsHelpPage();
3303 if ($helpId > 0) {
3304 return $this->frame('<div class="m-3">' . $this->productsHelpHtml() . '</div>', 'Produkte Hilfe', $this->productBarActions());
3305 }
3306 return $this->frame('<div class="alert alert-warning m-3">Hilfe konnte nicht angelegt werden.</div>', 'Produkte Hilfe', $this->productBarActions());
3307 }
3308
3309 private function orderStatusOptions(): array {
3310 return array(
3311 'new' => 'Neu',
3312 'payment_pending' => 'Zahlung offen',
3313 'paid' => 'Bezahlt',
3314 'processing' => 'In Bearbeitung',
3315 'shipped' => 'Versendet',
3316 'done' => 'Abgeschlossen',
3317 'cancelled' => 'Storniert',
3318 );
3319 }
3320
3321 private function paymentStatusOptions(): array {
3322 return array(
3323 'open' => 'Offen',
3324 'created' => 'Erstellt',
3325 'pending' => 'In Pruefung',
3326 'completed' => 'Abgeschlossen',
3327 'paid' => 'Bezahlt',
3328 'failed' => 'Fehlgeschlagen',
3329 'cancelled' => 'Abgebrochen',
3330 'refunded' => 'Erstattet',
3331 );
3332 }
3333
3334 private function shippingStatusOptions(): array {
3335 return array(
3336 'open' => 'Offen',
3337 'ready' => 'Bereit',
3338 'shipped' => 'Versendet',
3339 'delivered' => 'Zugestellt',
3340 'returned' => 'Retoure',
3341 );
3342 }
3343
3344 private function orderStatusBadge(string $status): string {
3345 $labels = $this->orderStatusOptions();
3346 $classes = array(
3347 'new' => 'text-bg-secondary',
3348 'payment_pending' => 'text-bg-warning',
3349 'paid' => 'text-bg-success',
3350 'processing' => 'text-bg-info',
3351 'shipped' => 'text-bg-primary',
3352 'done' => 'text-bg-success',
3353 'cancelled' => 'text-bg-danger',
3354 );
3355 return '<span class="badge ' . $this->h($classes[$status] ?? 'text-bg-secondary') . '">' . $this->h($labels[$status] ?? $status) . '</span>';
3356 }
3357
3358 private function paymentStatusBadge(string $status): string {
3359 $labels = $this->paymentStatusOptions();
3360 $classes = array(
3361 'open' => 'text-bg-secondary',
3362 'created' => 'text-bg-info',
3363 'pending' => 'text-bg-warning',
3364 'completed' => 'text-bg-success',
3365 'paid' => 'text-bg-success',
3366 'failed' => 'text-bg-danger',
3367 'cancelled' => 'text-bg-danger',
3368 'refunded' => 'text-bg-dark',
3369 );
3370 return '<span class="badge ' . $this->h($classes[$status] ?? 'text-bg-secondary') . '">' . $this->h($labels[$status] ?? $status) . '</span>';
3371 }
3372
3373 private function shippingStatusBadge(string $status): string {
3374 $labels = $this->shippingStatusOptions();
3375 $classes = array(
3376 'open' => 'text-bg-secondary',
3377 'ready' => 'text-bg-info',
3378 'shipped' => 'text-bg-primary',
3379 'delivered' => 'text-bg-success',
3380 'returned' => 'text-bg-warning',
3381 );
3382 return '<span class="badge ' . $this->h($classes[$status] ?? 'text-bg-secondary') . '">' . $this->h($labels[$status] ?? $status) . '</span>';
3383 }
3384
3385 private function channelLabel(string $channel): string {
3386 $labels = array(
3387 'shop' => 'Shop',
3388 'web' => 'Web',
3389 'amazon' => 'Amazon',
3390 'ebay' => 'eBay',
3391 'kleinanzeigen' => 'Kleinanzeigen',
3392 'mobile' => 'mobile.de',
3393 );
3394 return $labels[$channel] ?? $channel;
3395 }
3396
3397 private function paymentProviderLabel(string $provider): string {
3398 $labels = array(
3399 'bank_transfer' => 'Vorkasse / Ueberweisung',
3400 'invoice' => 'Rechnung',
3401 'paypal' => 'PayPal',
3402 'amazon_pay' => 'Amazon Pay',
3403 );
3404 return $labels[$provider] ?? $this->channelLabel($provider);
3405 }
3406
3407 private function channelBadge(string $channel): string {
3408 $class = in_array($channel, array('shop', 'web', ''), true) ? 'text-bg-secondary' : 'text-bg-info';
3409 $text = $channel === '' ? 'Shop' : $this->channelLabel($channel);
3410 return '<span class="badge ' . $class . '">' . $this->h($text) . '</span>';
3411 }
3412
3413 private function orderActions(): string {
3414 $helpId = $this->ensureShopOrdersHelpPage();
3415 $helpButton = $helpId > 0
3416 ? $this->openWinButton('?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId . '&dbx_ajax=1', 'Hilfe: Bestellungen', '<i class="bi bi-question-circle"></i><span class="visually-hidden"> Hilfe</span>', 'btn btn-outline-secondary btn-sm me-1', '72%', '82%')
3417 : '';
3418 return $helpButton;
3419 }
3420
3421 private function handleOrderAction($report): void {
3422 $deleteId = (int)dbx()->get_modul_var('delete_order', 0, 'int');
3423 if ($deleteId <= 0) {
3424 return;
3425 }
3426 if ($this->repo()->deleteOrder($deleteId)) {
3427 $report->_msg_success = 'Bestellung wurde in den Papierkorb verschoben.';
3428 } else {
3429 $report->_msg_error = 'Bestellung konnte nicht geloescht werden.';
3430 }
3431 }
3432
3433 public function order_report_next_record($report, $record) {
3434 $id = (int)($record['id'] ?? 0);
3435 $orderNo = (string)($record['order_no'] ?? '');
3436 $created = (string)($record['create_date'] ?? '');
3437 $channel = (string)($record['channel_key'] ?? 'shop');
3438 $items = (array)($record['items'] ?? array());
3439 $itemLines = '';
3440 foreach ($items as $item) {
3441 $itemLines .= '<div>' . (int)($item['qty'] ?? 0) . 'x <strong>' . $this->h($item['title'] ?? '') . '</strong> <code>' . $this->h($item['sku'] ?? '') . '</code></div>';
3442 }
3443 if ($itemLines === '') {
3444 $itemLines = '<span class="text-muted">Keine Positionen</span>';
3445 }
3446 $detailUrl = '?dbx_modul=dbxShop_admin&dbx_run1=order_detail&id=' . $id . '&dbx_ajax=1';
3447 $deleteUrl = '?dbx_modul=dbxShop_admin&dbx_run1=orders&delete_order=' . $id;
3448
3449 $externalId = trim((string)($record['payment_reference'] ?? ''));
3450 $sourceText = in_array($channel, array('', 'shop', 'web'), true) ? 'Shop-Bestellung' : 'Channel-Bestellung';
3451 $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>' . ($externalId !== '' ? '<small>Extern: ' . $this->h($externalId) . '</small>' : '') . '</div>';
3452 $record['customer_view'] = '<div class="dbx-shop-order-customer"><strong>' . $this->h($record['customer_name'] ?? '') . '</strong><small>' . $this->h($record['customer_email'] ?? '') . '</small></div>';
3453 $record['items_view'] = '<div class="dbx-shop-order-items-small">' . $itemLines . '</div>';
3454 $record['status_view'] = '<div class="dbx-shop-order-status-stack">'
3455 . '<span><small>Bestellung</small>' . $this->orderStatusBadge((string)($record['status'] ?? 'new')) . '</span>'
3456 . '<span><small>Versand</small>' . $this->shippingStatusBadge((string)($record['shipping_status'] ?? 'open')) . '</span>'
3457 . '</div>';
3458 $record['payment_view'] = '<div class="dbx-shop-order-payment">' . $this->paymentStatusBadge((string)($record['payment_status'] ?? 'open')) . '<small>' . $this->h($this->paymentProviderLabel((string)($record['payment_provider'] ?? ''))) . '</small><small>' . $this->h($record['payment_reference'] ?? '') . '</small></div>';
3459 $record['total_view'] = $this->money($record['total_gross'] ?? 0);
3460 $record['actions_view'] = '<span class="dbx-shop-order-actions">'
3461 . $this->openWinButton($detailUrl, 'Bestellung bearbeiten: ' . $orderNo, '<i class="bi bi-pencil-square"></i><span class="visually-hidden"> Bearbeiten</span>', 'btn btn-outline-primary btn-sm', '86%', '88%')
3462 . '<a class="btn btn-outline-danger btn-sm dbxConfirm" href="' . $this->h($deleteUrl) . '" data-confirm-title="<i class=\'bi bi-trash\'></i> Bestellung loeschen" data-confirm="Diese Bestellung wirklich in den Papierkorb verschieben?" data-confirm-hint="<small>Die Bestellung wird nicht physisch entfernt, sondern nur ausgeblendet.</small>" data-confirm-buttons="yesno" title="Bestellung loeschen"><i class="bi bi-trash"></i><span class="visually-hidden"> Loeschen</span></a>'
3463 . '</span>';
3464 return $record;
3465 }
3466
3467 private function orders(): string {
3468 $this->ensureSeed();
3469 $report = dbx()->get_system_obj('dbxReport');
3470 $report->init('shop-orders-report');
3471 $report->_action = '?dbx_modul=dbxShop_admin&dbx_run1=orders';
3472 $report->_mode = 'table';
3473 $report->_pages = true;
3474 $report->_create_sel_flds = true;
3475 $report->_but_pagination = 7;
3476 $report->_fld_id = 'id';
3477 $report->_msg_info = 'Bestellungen suchen, pruefen und Status bearbeiten.';
3478 $report->add_rep('bar_title', 'Bestellungen');
3479 $report->add_rep('bar_icon', 'bi-receipt');
3480 $report->add_rep('bar_subtitle', 'Shop- und Channel-Bestellungen');
3481 $report->add_rep('bar_class', 'dbx-module-bar');
3482 $report->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
3483 $report->add_rep('bar_actions_class', 'dbx-module-bar-actions');
3484 $report->add_rep('bar_title_pre', '');
3485 $report->add_rep('bar_title_heading_attrs', '');
3486 $report->add_rep('bar_middle', '');
3487 $report->add_rep('bar_extra', '');
3488 $report->add_rep('bar_actions', $this->orderActions());
3489 $report->add_rep('shop_admin_style', $this->shopAdminStyle());
3490 foreach (array(
3491 'order_view' => 'dbx-shop-col-order',
3492 'customer_view' => 'dbx-shop-col-customer',
3493 'items_view' => 'dbx-shop-col-items',
3494 'status_view' => 'dbx-shop-col-status',
3495 'payment_view' => 'dbx-shop-col-payment',
3496 'total_view' => 'dbx-shop-col-total',
3497 'actions_view' => 'dbx-shop-col-actions',
3498 ) as $field => $class) {
3499 $report->set_class_haeder($field, $class);
3500 $report->_class_body[$field] = $class;
3501 }
3502 $report->set_callback_owner($this);
3503 $report->set_callback('next_record', 'order_report_next_record');
3504 $report->create_selection_fields('dbxShop_admin|rpt-orders-selection');
3505 $this->handleOrderAction($report);
3506
3507 $query = trim((string)$report->get_fld_val('dbx_rwhere', '', 'parameter|max=120'));
3508 $requestedRowsPerPage = (int)$report->get_fld_val('dbx_rrows', 30, 'int');
3509 $rowsPerPage = $requestedRowsPerPage === 0 ? 0 : max(10, min(100, $requestedRowsPerPage));
3510 $position = $rowsPerPage === 0 ? 0 : max(0, (int)$report->get_fld_val('dbx_rpos', 0, 'int'));
3511 $sort = (string)$report->get_fld_val('dbx_rsort', 'create_date', 'parameter');
3512 $direction = strtoupper((string)$report->get_fld_val('dbx_rdesc', 'DESC', 'parameter')) === 'ASC' ? 'ASC' : 'DESC';
3513 $filters = array(
3514 'query' => $query,
3515 'status' => trim((string)$report->get_fld_val('status', '', 'parameter')),
3516 'payment_status' => trim((string)$report->get_fld_val('payment_status', '', 'parameter')),
3517 'shipping_status' => trim((string)$report->get_fld_val('shipping_status', '', 'parameter')),
3518 'channel_key' => trim((string)$report->get_fld_val('channel_key', '', 'parameter')),
3519 );
3520
3521 $filteredCount = $this->repo()->orderCount($filters);
3522 if ($rowsPerPage > 0 && $position >= $filteredCount && $filteredCount > 0) {
3523 $position = max(0, (int)(floor(($filteredCount - 1) / $rowsPerPage) * $rowsPerPage));
3524 }
3525 $orders = $this->repo()->orders($filters, $rowsPerPage, $position, $sort, $direction);
3526
3527 $report->_rflds = array(
3528 'order_view' => 'Bestellung',
3529 'customer_view' => 'Kunde',
3530 'items_view' => 'Positionen',
3531 'status_view' => 'Status',
3532 'payment_view' => 'Zahlung',
3533 'total_view' => 'Summe',
3534 'actions_view' => 'Aktion',
3535 );
3536 $report->_rpt_format = array(
3537 'order_view' => 'html',
3538 'customer_view' => 'html',
3539 'items_view' => 'html',
3540 'status_view' => 'html',
3541 'payment_view' => 'html',
3542 'total_view' => 'html',
3543 'actions_view' => 'html',
3544 );
3545 $report->_rrows = $rowsPerPage;
3546 $report->_rpos = $position;
3547 $report->_count_all = $this->repo()->orderCount(array());
3548 $report->_rcount = $filteredCount;
3549 $report->_rdata = $orders;
3550
3551 $content = $report->run();
3552 if ($filteredCount === 0) {
3553 $content .= '<div class="alert alert-info mx-3">Keine Bestellungen fuer diese Suche gefunden.</div>';
3554 }
3555 return $content;
3556 }
3557
3558 public function withdrawal_report_next_record($report, $record) {
3559 if (!is_array($record)) {
3560 return $record;
3561 }
3562 $record['request_view'] = '<div><strong>' . $this->h($record['order_no'] ?? 'Ohne Bestellnummer') . '</strong><br><small>' . $this->h($record['create_date'] ?? '') . '</small></div>';
3563 $record['customer_view'] = '<div><strong>' . $this->h($record['customer_name'] ?? '') . '</strong><br><small>' . $this->h($record['customer_email'] ?? '') . '</small></div>';
3564 $record['message_view'] = '<div class="small">' . nl2br($this->h($record['reason'] ?? '')) . '</div>';
3565 $status = (string)($record['status'] ?? '');
3566 $badge = in_array($status, array('accepted', 'refunded', 'closed'), true) ? 'text-bg-success' : (in_array($status, array('rejected'), true) ? 'text-bg-danger' : 'text-bg-warning');
3567 $record['status_view'] = '<span class="badge ' . $badge . '">' . $this->h($status) . '</span>';
3568 $id = (int)($record['id'] ?? 0);
3569 $base = '?dbx_modul=dbxShop_admin&dbx_run1=returns&withdrawal_id=' . $id . '&withdrawal_status=';
3570 $record['actions_view'] =
3571 '<div class="btn-group btn-group-sm" role="group">'
3572 . '<a class="btn btn-outline-secondary" href="' . $base . 'processing" title="In Bearbeitung"><i class="bi bi-hourglass-split"></i></a>'
3573 . '<a class="btn btn-outline-success dbxConfirm" href="' . $base . 'accepted" data-confirm-title="Widerruf annehmen" data-confirm="Widerruf annehmen und reservierten Bestand zurueckbuchen?" data-confirm-buttons="yesno" title="Annehmen"><i class="bi bi-check2"></i></a>'
3574 . '<a class="btn btn-outline-primary dbxConfirm" href="' . $base . 'refunded" data-confirm-title="Widerruf erstatten" data-confirm="Widerruf als erstattet markieren und reservierten Bestand zurueckbuchen?" data-confirm-buttons="yesno" title="Erstattet"><i class="bi bi-cash-coin"></i></a>'
3575 . '<a class="btn btn-outline-danger" href="' . $base . 'rejected" title="Ablehnen"><i class="bi bi-x-lg"></i></a>'
3576 . '<a class="btn btn-outline-secondary" href="' . $base . 'closed" title="Schliessen"><i class="bi bi-archive"></i></a>'
3577 . '</div>';
3578 return $record;
3579 }
3580
3581 private function returns(): string {
3582 $this->ensureSeed();
3583 $withdrawalId = (int)dbx()->get_modul_var('withdrawal_id', 0, 'int');
3584 $withdrawalStatus = (string)dbx()->get_modul_var('withdrawal_status', '', 'parameter');
3585 if ($withdrawalId > 0 && $withdrawalStatus !== '') {
3586 $this->repo()->updateWithdrawalAdmin($withdrawalId, $withdrawalStatus);
3587 }
3588 $report = dbx()->get_system_obj('dbxReport');
3589 $report->init('shop-orders-report');
3590 $report->_action = '?dbx_modul=dbxShop_admin&dbx_run1=returns';
3591 $report->_mode = 'table';
3592 $report->_pages = true;
3593 $report->_create_sel_flds = true;
3594 $report->_but_pagination = 7;
3595 $report->_fld_id = 'id';
3596 $report->_msg_info = 'Eingegangene Widerrufe suchen und bearbeiten.';
3597 $report->add_rep('bar_title', 'Widerrufe');
3598 $report->add_rep('bar_icon', 'bi-arrow-counterclockwise');
3599 $report->add_rep('bar_subtitle', 'Widerrufe und Retouren aus dem Shop');
3600 $report->add_rep('bar_class', 'dbx-module-bar');
3601 $report->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
3602 $report->add_rep('bar_actions_class', 'dbx-module-bar-actions');
3603 $report->add_rep('bar_title_pre', '');
3604 $report->add_rep('bar_title_heading_attrs', '');
3605 $report->add_rep('bar_middle', '');
3606 $report->add_rep('bar_extra', '');
3607 $shopService = dbx()->get_include_obj('dbxShopService', 'dbxShop');
3608 $pages = is_object($shopService) && method_exists($shopService, 'ensureShopLegalPages') ? $shopService->ensureShopLegalPages() : array();
3609 $withdrawalCid = (int)($pages['withdrawal'] ?? 0);
3610 $cmsButton = $withdrawalCid > 0
3611 ? $this->openWinButton('?dbx_modul=dbxContent_admin&dbx_run1=cms&cid=' . $withdrawalCid . '&dbx_ajax=1', 'Widerrufstext bearbeiten', '<i class="bi bi-pencil-square"></i><span class="visually-hidden"> CMS bearbeiten</span>', 'btn btn-outline-primary btn-sm me-1', '94%', '92%')
3612 : '';
3613 $report->add_rep('bar_actions',
3614 $cmsButton
3615 . $this->openWinButton('?dbx_modul=dbxShop&dbx_run1=withdrawal&dbx_ajax=1', 'Widerrufsseite 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%')
3616 );
3617 $report->add_rep('shop_admin_style', $this->shopAdminStyle());
3618 $report->set_callback_owner($this);
3619 $report->set_callback('next_record', 'withdrawal_report_next_record');
3620 $report->create_selection_fields('dbxShop_admin|rpt-withdrawals-selection');
3621
3622 $query = trim((string)$report->get_fld_val('dbx_rwhere', '', 'parameter|max=120'));
3623 $rowsPerPage = (int)$report->get_fld_val('dbx_rrows', 30, 'int');
3624 $rowsPerPage = $rowsPerPage === 0 ? 0 : max(10, min(100, $rowsPerPage));
3625 $position = $rowsPerPage === 0 ? 0 : max(0, (int)$report->get_fld_val('dbx_rpos', 0, 'int'));
3626 $filters = array(
3627 'query' => $query,
3628 'status' => trim((string)$report->get_fld_val('status', '', 'parameter')),
3629 );
3630 $filteredCount = $this->repo()->withdrawalCount($filters);
3631 $rows = $this->repo()->withdrawals($filters, $rowsPerPage, $position);
3632
3633 $report->_rflds = array(
3634 'request_view' => 'Widerruf',
3635 'customer_view' => 'Kunde',
3636 'message_view' => 'Nachricht',
3637 'status_view' => 'Status',
3638 'actions_view' => 'Aktion',
3639 );
3640 $report->_rpt_format = array(
3641 'request_view' => 'html',
3642 'customer_view' => 'html',
3643 'message_view' => 'html',
3644 'status_view' => 'html',
3645 'actions_view' => 'html',
3646 );
3647 $report->_rrows = $rowsPerPage;
3648 $report->_rpos = $position;
3649 $report->_count_all = $this->repo()->withdrawalCount(array());
3650 $report->_rcount = $filteredCount;
3651 $report->_rdata = $rows;
3652
3653 $content = $report->run();
3654 if ($filteredCount === 0) {
3655 $content .= '<div class="alert alert-info mx-3">Keine Widerrufe fuer diese Suche gefunden.</div>';
3656 }
3657 return $content;
3658 }
3659
3660 private function orderMetaHtml(array $order): string {
3661 $rows = array(
3662 'Bestellnummer' => $order['order_no'] ?? '',
3663 'Erstellt' => $order['create_date'] ?? '',
3664 'Kunde' => trim((string)($order['customer_name'] ?? '') . ' <' . (string)($order['customer_email'] ?? '') . '>'),
3665 'Telefon' => $order['customer_phone'] ?? '',
3666 'Lieferadresse' => $order['shipping_address'] ?? '',
3667 'Herkunft' => $this->channelLabel((string)($order['channel_key'] ?? 'shop')) . (in_array((string)($order['channel_key'] ?? 'shop'), array('shop', 'web', ''), true) ? ' Bestellung' : ' Channel-Bestellung'),
3668 'Externe Bestellnummer' => $order['payment_reference'] ?? '',
3669 'Zahlungsart' => $this->paymentProviderLabel((string)($order['payment_provider'] ?? '')),
3670 'Zahlungsstatus' => $order['payment_status'] ?? '',
3671 'Rechnungsnummer' => $order['invoice_no'] ?? '',
3672 'Rechnungsdatum' => $order['invoice_date'] ?? '',
3673 'Rechnungs-PDF' => trim((string)($order['invoice_pdf_path'] ?? '')) !== '' ? (string)$order['invoice_pdf_path'] : 'noch nicht erzeugt',
3674 'Bestand reserviert' => !empty($order['stock_reserved']) ? 'Ja' : 'Nein',
3675 'Bestand zurueckgebucht' => !empty($order['stock_released']) ? 'Ja, ' . (string)($order['stock_released_date'] ?? '') : 'Nein',
3676 'Versandstatus' => $order['shipping_status'] ?? '',
3677 'Versanddienstleister' => $order['shipping_provider'] ?? '',
3678 'Trackingnummer' => $order['tracking_no'] ?? '',
3679 'Summe' => $this->money($order['total_gross'] ?? 0),
3680 );
3681 $html = '<dl class="dbx-shop-order-meta">';
3682 foreach ($rows as $label => $value) {
3683 $html .= '<dt>' . $this->h($label) . '</dt><dd>' . $this->h($value) . '</dd>';
3684 }
3685 $html .= '</dl>';
3686 return $html;
3687 }
3688
3689 private function orderHistoryHtml(array $order): string {
3690 $rows = (array)($order['history'] ?? array());
3691 if ($rows === array()) {
3692 return '<div class="text-muted small">Noch keine Historie gespeichert.</div>';
3693 }
3694 $html = '<div class="dbx-shop-order-history">';
3695 foreach ($rows as $row) {
3696 $event = (string)($row['event_type'] ?? '');
3697 $old = (string)($row['old_value'] ?? '');
3698 $new = (string)($row['new_value'] ?? '');
3699 $msg = (string)($row['message'] ?? '');
3700 $html .= '<div class="dbx-shop-order-history-item">'
3701 . '<strong>' . $this->h($event) . '</strong>'
3702 . '<small>' . $this->h($row['create_date'] ?? '') . '</small>'
3703 . ($old !== '' || $new !== '' ? '<code>' . $this->h($old) . ' -> ' . $this->h($new) . '</code>' : '')
3704 . ($msg !== '' ? '<span>' . $this->h($msg) . '</span>' : '')
3705 . '</div>';
3706 }
3707 $html .= '</div>';
3708 return $html;
3709 }
3710
3711 private function orderWithdrawalsHtml(array $order): string {
3712 $rows = (array)($order['withdrawals'] ?? array());
3713 if ($rows === array()) {
3714 return '<div class="text-muted small">Kein Widerruf zu dieser Bestellung.</div>';
3715 }
3716 $html = '<div class="dbx-shop-order-withdrawals">';
3717 foreach ($rows as $row) {
3718 $html .= '<div class="alert alert-warning py-2 mb-2">'
3719 . '<strong>' . $this->h($row['customer_name'] ?? '') . '</strong> '
3720 . '<span class="badge text-bg-warning">' . $this->h($row['status'] ?? '') . '</span>'
3721 . '<br><small>' . $this->h($row['create_date'] ?? '') . ' · ' . $this->h($row['customer_email'] ?? '') . '</small>'
3722 . '<div class="mt-1">' . nl2br($this->h($row['reason'] ?? '')) . '</div>'
3723 . '</div>';
3724 }
3725 $html .= '</div>';
3726 return $html;
3727 }
3728
3729 private function orderItemsHtml(array $order): string {
3730 $items = (array)($order['items'] ?? array());
3731 if ($items === array()) {
3732 return '<div class="text-muted small">Keine Positionen gespeichert.</div>';
3733 }
3734 $html = '<div class="table-responsive"><table class="table table-sm table-bordered dbx-shop-order-items-table"><thead><tr>'
3735 . '<th>Artikel</th><th class="text-end">Menge</th><th class="text-end">Einzelpreis</th><th class="text-end">MwSt.</th><th class="text-end">Versand</th><th class="text-end">Summe</th>'
3736 . '</tr></thead><tbody>';
3737 foreach ($items as $item) {
3738 $html .= '<tr>'
3739 . '<td><strong>' . $this->h($item['title'] ?? '') . '</strong><br><code>' . $this->h($item['sku'] ?? '') . '</code></td>'
3740 . '<td class="text-end">' . (int)($item['qty'] ?? 0) . '</td>'
3741 . '<td class="text-end">' . $this->money($item['price_gross'] ?? 0) . '</td>'
3742 . '<td class="text-end">' . $this->h(number_format((float)($item['tax_rate'] ?? 0), 2, ',', '.')) . ' %</td>'
3743 . '<td class="text-end">' . $this->money($item['shipping_gross'] ?? 0) . '</td>'
3744 . '<td class="text-end">' . $this->money($item['total_gross'] ?? 0) . '</td>'
3745 . '</tr>';
3746 }
3747 $html .= '</tbody></table></div>';
3748 return $html;
3749 }
3750
3751 private function orderPayloadHtml(array $order): string {
3752 $blocks = array(
3753 'Zahlungs-/Channel-Payload' => trim((string)($order['payment_payload'] ?? '')),
3754 'Rechtstext-Snapshot' => trim((string)($order['legal_snapshot'] ?? '')),
3755 'Widerruf-Snapshot' => trim((string)($order['withdrawal_snapshot'] ?? '')),
3756 );
3757 $html = '';
3758 foreach ($blocks as $title => $payload) {
3759 if ($payload === '') {
3760 continue;
3761 }
3762 $decoded = json_decode($payload, true);
3763 if (is_array($decoded)) {
3764 $payload = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3765 }
3766 $html .= '<details class="mb-2"><summary>' . $this->h($title) . '</summary><pre>' . $this->h($payload) . '</pre></details>';
3767 }
3768 if ($html === '') {
3769 return '<div class="text-muted small">Keine technische Rueckmeldung und kein Rechtstext-Snapshot gespeichert.</div>';
3770 }
3771 return $html;
3772 }
3773
3774 private function statusMailChangesHtml(array $before, array $order): string {
3775 $rows = array(
3776 'Bestellstatus' => array($this->orderStatusOptions(), 'status'),
3777 'Zahlungsstatus' => array($this->paymentStatusOptions(), 'payment_status'),
3778 'Versandstatus' => array($this->shippingStatusOptions(), 'shipping_status'),
3779 );
3780 $html = '';
3781 foreach ($rows as $label => $cfg) {
3782 [$options, $field] = $cfg;
3783 $old = (string)($before[$field] ?? '');
3784 $new = (string)($order[$field] ?? '');
3785 if ($old === $new) {
3786 continue;
3787 }
3788 $html .= '<tr><th align="left">' . $this->h($label) . '</th><td>' . $this->h($options[$old] ?? $old) . '</td><td>' . $this->h($options[$new] ?? $new) . '</td></tr>';
3789 }
3790 if ($html === '') {
3791 return '<dl>'
3792 . '<dt>Bestellstatus</dt><dd>' . $this->h($this->orderStatusOptions()[(string)($order['status'] ?? '')] ?? (string)($order['status'] ?? '')) . '</dd>'
3793 . '<dt>Zahlungsstatus</dt><dd>' . $this->h($this->paymentStatusOptions()[(string)($order['payment_status'] ?? '')] ?? (string)($order['payment_status'] ?? '')) . '</dd>'
3794 . '<dt>Versandstatus</dt><dd>' . $this->h($this->shippingStatusOptions()[(string)($order['shipping_status'] ?? '')] ?? (string)($order['shipping_status'] ?? '')) . '</dd>'
3795 . '</dl>';
3796 }
3797 return '<table border="0" cellpadding="6" cellspacing="0">'
3798 . '<thead><tr><th align="left">Feld</th><th align="left">Vorher</th><th align="left">Jetzt</th></tr></thead>'
3799 . '<tbody>' . $html . '</tbody></table>';
3800 }
3801
3802 private function sendOrderStatusMail(array $before, array $order): array {
3803 $cfg = $this->shopConfig();
3804 $from = trim((string)($cfg['mail_from'] ?? ''));
3805 $to = trim((string)($order['customer_email'] ?? ''));
3806 if ($from === '') {
3807 return array(false, 'Kundenmail wurde nicht gesendet: Mail-Absender fehlt in den Shop-Einstellungen.');
3808 }
3809 if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
3810 return array(false, 'Kundenmail wurde nicht gesendet: Die Bestellung hat keine gueltige Kunden-E-Mail.');
3811 }
3812
3813 $orderNo = (string)($order['order_no'] ?? '');
3814 $subject = 'Aktualisierung Ihrer Bestellung ' . $orderNo;
3815 $trackingNo = trim((string)($order['tracking_no'] ?? ''));
3816 $trackingUrl = trim((string)($order['tracking_url'] ?? ''));
3817 $invoiceNo = trim((string)($order['invoice_no'] ?? ''));
3818 $extra = '';
3819 if ($trackingNo !== '') {
3820 $extra .= '<p><strong>Trackingnummer:</strong> ' . $this->h($trackingNo) . '</p>';
3821 }
3822 if ($trackingUrl !== '') {
3823 $extra .= '<p><a href="' . $this->h($trackingUrl) . '">Sendung verfolgen</a></p>';
3824 }
3825 if ($invoiceNo !== '') {
3826 $extra .= '<p><strong>Rechnung:</strong> ' . $this->h($invoiceNo) . '</p>';
3827 }
3828 $html = '<h2>Ihre Bestellung wurde aktualisiert</h2>'
3829 . '<p>Bestellnummer: <strong>' . $this->h($orderNo) . '</strong></p>'
3830 . $this->statusMailChangesHtml($before, $order)
3831 . $extra
3832 . '<p>Viele Gruesse<br>Ihr Shop-Team</p>';
3833
3834 try {
3835 dbx()->send_mail($from, $to, $subject, $html, 'html');
3836 $this->repo()->addOrderHistory((int)($order['id'] ?? 0), 'customer_mail', '', $to, 'Statusbenachrichtigung wurde an den Kunden gesendet.');
3837 return array(true, 'Kundenmail wurde gesendet.');
3838 } catch (\Throwable $e) {
3839 dbx()->sys_msg('error', 'dbxShop_admin', (string)($order['id'] ?? ''), 'order status mail failed', $e->getMessage());
3840 return array(false, 'Kundenmail konnte nicht gesendet werden: ' . $e->getMessage());
3841 }
3842 }
3843
3844 private function notifyCustomerHint(array $order): string {
3845 $email = trim((string)($order['customer_email'] ?? ''));
3846 return $email === ''
3847 ? 'Keine Kunden-E-Mail vorhanden. Es kann keine Statusmail gesendet werden.'
3848 : 'Status erst speichern. Danach kann ueber den Mail-Button in der Bar eine kurze Statusaktualisierung an ' . $email . ' gesendet werden.';
3849 }
3850
3851 private function orderQuickActionsHtml(array $order): string {
3852 $id = (int)($order['id'] ?? 0);
3853 if ($id <= 0) {
3854 return '';
3855 }
3856 $base = '?dbx_modul=dbxShop_admin&dbx_run1=order_detail&dbx_run2=quick_action&id=' . $id . '&dbx_ajax=1&order_action=';
3857 $actions = array(
3858 'mark_paid' => array('bi-cash-coin', 'Bezahlt', 'Bestellung als bezahlt markieren?'),
3859 'processing' => array('bi-tools', 'Bearbeitung', 'Bestellung in Bearbeitung setzen?'),
3860 'ready' => array('bi-box-seam', 'Versandbereit', 'Bestellung als versandbereit markieren?'),
3861 'shipped' => array('bi-truck', 'Versendet', 'Bestellung als versendet markieren?'),
3862 'delivered' => array('bi-check2-circle', 'Abgeschlossen', 'Bestellung als zugestellt und abgeschlossen markieren?'),
3863 'cancel' => array('bi-x-circle', 'Stornieren', 'Bestellung stornieren? Reservierter Bestand wird bei Bedarf freigegeben.'),
3864 'refund' => array('bi-arrow-counterclockwise', 'Erstattet', 'Bestellung als erstattet markieren? Reservierter Bestand wird bei Bedarf freigegeben.'),
3865 );
3866 $html = '<div class="dbx-shop-order-quick-actions" data-dbx="lib=confirm|class=dbxConfirm|bind=link">'
3867 . '<strong>Schnellaktionen</strong><div class="dbx-shop-order-quick-action-buttons">';
3868 foreach ($actions as $action => $cfg) {
3869 [$icon, $label, $confirm] = $cfg;
3870 $btnClass = in_array($action, array('cancel', 'refund'), true) ? 'btn-outline-danger' : 'btn-outline-primary';
3871 $html .= '<a class="btn btn-sm ' . $btnClass . ' dbxConfirm" href="' . $this->h($base . rawurlencode($action)) . '"'
3872 . ' data-confirm-title="<i class=\'bi ' . $this->h($icon) . '\'></i> ' . $this->h($label) . '"'
3873 . ' data-confirm="' . $this->h($confirm) . '"'
3874 . ' data-confirm-buttons="yesno"'
3875 . ' title="' . $this->h($label) . '">'
3876 . '<i class="bi ' . $this->h($icon) . '"></i> ' . $this->h($label) . '</a>';
3877 }
3878 $html .= '</div></div>';
3879 return $html;
3880 }
3881
3882 private function orderDetailActions(int $id): string {
3883 $helpId = $this->ensureShopOrdersHelpPage();
3884 $helpButton = $helpId > 0
3885 ? $this->openWinButton('?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId . '&dbx_ajax=1', 'Hilfe: Bestellungen', '<i class="bi bi-question-circle"></i><span class="visually-hidden"> Hilfe</span>', 'btn btn-outline-secondary btn-sm ms-1', '72%', '82%')
3886 : '';
3887 $deleteUrl = '?dbx_modul=dbxShop_admin&dbx_run1=orders&delete_order=' . $id;
3888 $mailUrl = '?dbx_modul=dbxShop_admin&dbx_run1=order_detail&dbx_run2=send_status_mail&id=' . $id . '&dbx_ajax=1';
3889 return '<button class="btn btn-primary btn-sm" type="submit" title="Bestellung speichern"><i class="bi bi-save"></i><span class="visually-hidden"> Speichern</span></button>'
3890 . $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=order_invoice&id=' . $id . '&dbx_ajax=1', 'Rechnung anzeigen', '<i class="bi bi-file-earmark-text"></i><span class="visually-hidden"> Rechnung</span>', 'btn btn-outline-primary btn-sm ms-1', '82%', '86%')
3891 . '<a class="btn btn-outline-danger btn-sm ms-1" href="?dbx_modul=dbxShop_admin&dbx_run1=order_invoice_pdf&id=' . $id . '" target="_blank" rel="noopener" title="Rechnung als PDF erzeugen/oeffnen"><i class="bi bi-file-earmark-pdf"></i><span class="visually-hidden"> PDF</span></a>'
3892 . '<a class="btn btn-outline-primary btn-sm ms-1 dbxConfirm" href="' . $this->h($mailUrl) . '" data-confirm-title="<i class=\'bi bi-envelope\'></i> Kundenmail senden" data-confirm="Aktuellen Bestellstatus per E-Mail an den Kunden senden?" data-confirm-buttons="yesno" title="Kundenmail senden"><i class="bi bi-envelope"></i><span class="visually-hidden"> Kundenmail</span></a>'
3893 . '<a class="btn btn-outline-danger btn-sm ms-1 dbxConfirm" href="' . $this->h($deleteUrl) . '" data-confirm-title="<i class=\'bi bi-trash\'></i> Bestellung loeschen" data-confirm="Diese Bestellung wirklich in den Papierkorb verschieben?" data-confirm-buttons="yesno" title="Bestellung loeschen"><i class="bi bi-trash"></i><span class="visually-hidden"> Loeschen</span></a>'
3894 . $helpButton
3895 . $this->openWinButton('?dbx_modul=dbxShop_admin&dbx_run1=orders&dbx_ajax=1', 'Bestellungen', '<i class="bi bi-table"></i><span class="visually-hidden"> Bestellliste</span>', 'btn btn-outline-secondary btn-sm ms-1', '92%', '88%');
3896 }
3897
3898 private function orderDetail(): string {
3899 $id = (int)dbx()->get_modul_var('id', 0, 'int');
3900 $order = $id > 0 ? $this->repo()->orderById($id) : null;
3901 if (!is_array($order)) {
3902 return $this->frame('<div class="alert alert-warning m-3">Bestellung nicht gefunden.</div>', 'Bestellung bearbeiten', $this->orderActions());
3903 }
3904 $quickMessage = '';
3905 $quickError = '';
3906 $quickAction = (string)dbx()->get_modul_var('order_action', '', 'parameter');
3907 if ((string)dbx()->get_modul_var('dbx_run2', '', 'parameter') === 'quick_action' && $quickAction !== '') {
3908 [$quickOk, $quickMsg] = $this->repo()->updateOrderQuickAction($id, $quickAction);
3909 if ($quickOk) {
3910 $quickMessage = $quickMsg;
3911 $order = $this->repo()->orderById($id) ?: $order;
3912 } else {
3913 $quickError = $quickMsg;
3914 }
3915 }
3916 $mailMessage = '';
3917 $mailError = '';
3918 $sendStatusMail = (string)dbx()->get_modul_var('dbx_run2', '', 'parameter') === 'send_status_mail'
3919 || (string)($_GET['dbx_run2'] ?? '') === 'send_status_mail';
3920 if ($sendStatusMail) {
3921 [$mailOk, $mailMsg] = $this->sendOrderStatusMail($order, $order);
3922 if ($mailOk) {
3923 $mailMessage = $mailMsg;
3924 $order = $this->repo()->orderById($id) ?: $order;
3925 } else {
3926 $mailError = $mailMsg;
3927 }
3928 }
3929
3930 $form = dbx()->get_system_obj('dbxForm');
3931 $form->init('shop-order-form', 'shop-order-form');
3932 $form->_dd = 'dbxShop|shopOrder';
3933 $form->_data = $order;
3934 $form->_rid = $id;
3935 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=order_detail&id=' . $id;
3936 if ((int)dbx()->get_modul_var('dbx_ajax', 0, 'int') === 1) {
3937 $form->_action .= '&dbx_ajax=1';
3938 }
3939 $form->set_activ_id($id);
3940 $form->add_rep('bar_title', 'Bestellung bearbeiten');
3941 $form->add_rep('bar_icon', 'bi-receipt');
3942 $form->add_rep('bar_subtitle', (string)($order['order_no'] ?? ''));
3943 $form->add_rep('bar_class', 'dbx-module-bar');
3944 $form->add_rep('bar_title_class', 'dbx-module-bar-titleblock');
3945 $form->add_rep('bar_title_pre', '');
3946 $form->add_rep('bar_title_heading_attrs', '');
3947 $form->add_rep('bar_middle', '');
3948 $form->add_rep('bar_actions_class', 'dbx-module-bar-actions');
3949 $form->add_rep('bar_actions', $this->orderDetailActions($id));
3950 $form->add_rep('bar_extra', '');
3951 $form->add_rep('shop_admin_style', $this->shopAdminStyle());
3952 $form->_msg_info = 'Status, Zahlungsstatus, Zahlungsreferenz und interne Notiz bearbeiten.';
3953 $form->add_fld('status', tpl: 'select-single-label', label: 'Bestellstatus', options: $this->orderStatusOptions());
3954 $form->add_fld('payment_status', tpl: 'select-single-label', label: 'Zahlungsstatus', options: $this->paymentStatusOptions());
3955 $form->add_fld('payment_reference', tpl: 'text-label', label: 'Zahlungsreferenz', rules: '*|max=180', placeholder: 'PAYID-xxxx oder Channel-Order-ID');
3956 $form->add_fld('invoice_no', tpl: 'text-label', label: 'Rechnungsnummer', rules: '*|max=60', placeholder: 'R2026-00001');
3957 $form->add_fld('invoice_date', tpl: 'text-label', label: 'Rechnungsdatum', rules: '*|date', placeholder: date('Y-m-d'));
3958 $form->add_fld('shipping_status', tpl: 'select-single-label', label: 'Versandstatus', options: $this->shippingStatusOptions());
3959 $form->add_fld('shipping_provider', tpl: 'text-label', label: 'Versanddienstleister', rules: '*|max=120', placeholder: 'DHL, UPS, Deutsche Post');
3960 $form->add_fld('tracking_no', tpl: 'text-label', label: 'Trackingnummer', rules: '*|max=180', placeholder: '00340434123456789012');
3961 $form->add_fld('tracking_url', tpl: 'text-label', label: 'Tracking-URL', rules: '*|max=255', placeholder: 'https://www.dhl.de/de/privatkunden.html?piececode=...');
3962 $form->add_fld('shipped_date', tpl: 'text-label', label: 'Versendet am', rules: '*|datetime', placeholder: date('Y-m-d H:i:s'));
3963 $form->add_fld('note', tpl: 'textarea-label', label: 'Interne Notiz', rules: '*|max=5000', data: 'rows=5', placeholder: 'Rueckfrage, Versandhinweis oder interner Bearbeitungsvermerk');
3964 $form->add_rep('order_notice', '');
3965
3966 if ($form->submit()) {
3967 if (!$form->errors()) {
3968 if ($this->repo()->updateOrderAdmin($id, $_POST)) {
3969 $form->_msg_success = 'Bestellung gespeichert.';
3970 $order = $this->repo()->orderById($id) ?: $order;
3971 $form->_data = $order;
3972 } else {
3973 $form->_msg_error = 'Bestellung konnte nicht gespeichert werden.';
3974 }
3975 } else {
3976 $form->_msg_error = 'Bitte Eingaben pruefen.';
3977 }
3978 }
3979
3980 $actionMessage = $quickMessage !== '' ? $quickMessage : $mailMessage;
3981 $actionError = $quickError !== '' ? $quickError : $mailError;
3982 if ($actionMessage !== '' || $actionError !== '') {
3983 $form->_form_submit = 1;
3984 $form->_msg_info = '';
3985 if ($actionError !== '') {
3986 $form->_msg_error = $actionError;
3987 $form->add_fld_error('general', $actionError);
3988 } else {
3989 $form->_msg_success = $actionMessage;
3990 }
3991 }
3992
3993 $form->add_rep('notify_customer_hint', $this->h($this->notifyCustomerHint($order)));
3994 $form->add_rep('order_quick_actions', $this->orderQuickActionsHtml($order));
3995 $form->add_obj('order_meta', 'obj-value', $this->orderMetaHtml($order));
3996 $form->add_obj('order_items', 'obj-value', $this->orderItemsHtml($order));
3997 $form->add_obj('order_payload', 'obj-value', $this->orderPayloadHtml($order));
3998 $form->add_obj('order_history', 'obj-value', $this->orderHistoryHtml($order));
3999 $form->add_obj('order_withdrawals', 'obj-value', $this->orderWithdrawalsHtml($order));
4000 return $form->run();
4001 }
4002
4003 private function orderInvoice(): string {
4004 $id = (int)dbx()->get_modul_var('id', 0, 'int');
4005 $order = $id > 0 ? $this->repo()->orderById($id) : null;
4006 if (!is_array($order)) {
4007 return $this->frame('<div class="alert alert-warning m-3">Bestellung nicht gefunden.</div>', 'Rechnung');
4008 }
4009 $invoiceNo = trim((string)($order['invoice_no'] ?? ''));
4010 if ($invoiceNo === '') {
4011 $invoiceNo = 'Entwurf';
4012 }
4013 $rows = '';
4014 foreach ((array)($order['items'] ?? array()) as $item) {
4015 $rows .= '<tr>'
4016 . '<td><strong>' . $this->h($item['title'] ?? '') . '</strong><br><code>' . $this->h($item['sku'] ?? '') . '</code></td>'
4017 . '<td class="text-end">' . (int)($item['qty'] ?? 0) . '</td>'
4018 . '<td class="text-end">' . $this->money($item['price_gross'] ?? 0) . '</td>'
4019 . '<td class="text-end">' . $this->h(number_format((float)($item['tax_rate'] ?? 0), 2, ',', '.')) . ' %</td>'
4020 . '<td class="text-end">' . $this->money($item['total_gross'] ?? 0) . '</td>'
4021 . '</tr>';
4022 }
4023 $html = '<section class="dbx-shop-invoice p-4 bg-white">'
4024 . '<div class="d-flex justify-content-between align-items-start mb-4"><div><h2>Rechnung</h2><strong>' . $this->h($invoiceNo) . '</strong><br><small>' . $this->h($order['invoice_date'] ?? date('Y-m-d')) . '</small></div><button class="btn btn-outline-primary btn-sm" onclick="window.print()"><i class="bi bi-printer"></i> Drucken</button></div>'
4025 . '<dl class="dbx-shop-order-meta mb-4">'
4026 . '<dt>Bestellung</dt><dd>' . $this->h($order['order_no'] ?? '') . '</dd>'
4027 . '<dt>Kunde</dt><dd>' . $this->h($order['customer_name'] ?? '') . '<br>' . $this->h($order['customer_email'] ?? '') . '</dd>'
4028 . '<dt>Lieferadresse</dt><dd>' . nl2br($this->h($order['shipping_address'] ?? '')) . '</dd>'
4029 . '</dl>'
4030 . '<div class="table-responsive"><table class="table table-sm table-bordered"><thead><tr><th>Artikel</th><th class="text-end">Menge</th><th class="text-end">Einzelpreis</th><th class="text-end">MwSt.</th><th class="text-end">Summe</th></tr></thead><tbody>'
4031 . $rows
4032 . '</tbody><tfoot><tr><th colspan="4" class="text-end">Gesamt</th><th class="text-end">' . $this->money($order['total_gross'] ?? 0) . '</th></tr></tfoot></table></div>'
4033 . '<p class="text-muted small">Dieser Beleg basiert auf dem gespeicherten Bestell-Snapshot. Rechtliche Pflichtangaben und Nummernkreis bitte vor Livebetrieb final pruefen.</p>'
4034 . '</section>';
4035 return $this->frame($html, 'Rechnung ' . $invoiceNo, '');
4036 }
4037
4038 private function orderInvoicePdf(): string {
4039 $id = (int)dbx()->get_modul_var('id', 0, 'int');
4040 $order = $id > 0 ? $this->repo()->ensureOrderInvoicePdf($id) : null;
4041 if (!is_array($order)) {
4042 return $this->frame('<div class="alert alert-warning m-3">Rechnungs-PDF konnte nicht erzeugt werden.</div>', 'Rechnung');
4043 }
4044 $file = $this->repo()->invoicePdfAbsolutePath($order);
4045 if ($file === '') {
4046 return $this->frame('<div class="alert alert-warning m-3">Rechnungs-PDF ist nicht verfuegbar.</div>', 'Rechnung');
4047 }
4048 if (!headers_sent()) {
4049 header('Content-Type: application/pdf');
4050 header('Content-Disposition: inline; filename="' . basename($file) . '"');
4051 header('Content-Length: ' . filesize($file));
4052 }
4053 readfile($file);
4054 exit;
4055 }
4056
4057 private function shopAdminCardForm(string $fid, string $dd, array $data, int $id, string $action, string $shopAction, string $saveAction, string $titleHtml, string $subtitle = '', string $cardClass = '') {
4058 $form = dbx()->get_system_obj('dbxForm');
4059 $form->init($fid, 'shop-admin-card-form');
4060 $form->_dd = $dd;
4061 $form->_data = $data + array('id' => $id);
4062 $form->_rid = $id;
4063 $form->_action = $action;
4064 $form->set_activ_id($id);
4065 $form->add_rep('form_class', 'dbx-shop-admin-card-dbXForm');
4066 $form->add_rep('form_attrs', 'data-target="dbxForm_{i}" data-dbx="lib=confirm|class=dbxConfirm|bind=button"');
4067 $form->add_rep('shop_action', $this->h($shopAction));
4068 $form->add_rep('save_action', $this->h($saveAction));
4069 $form->add_rep('record_id', (string)$id);
4070 $form->add_rep('extra_hidden', '');
4071 $form->add_rep('card_title', $titleHtml);
4072 $form->add_rep('card_subtitle', $this->h($subtitle));
4073 $form->add_rep('card_badges', '');
4074 $form->add_rep('card_class', $this->h($cardClass));
4075 $form->add_rep('form_body', '');
4076 $form->add_rep('delete_button', '');
4077 $form->_msg_info = '';
4078 return $form;
4079 }
4080
4081 private function shopAdminCardDeleteButton(string $action, string $title, string $message): string {
4082 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>';
4083 }
4084
4085 private function activeBadge(array $row): string {
4086 return ((int)($row['active'] ?? 0) === 1)
4087 ? '<span class="badge text-bg-success">Aktiv</span>'
4088 : '<span class="badge text-bg-secondary">Inaktiv</span>';
4089 }
4090
4091 private function groups(): string {
4092 $this->ensureSeed();
4093 if ($this->posted('delete_product_group')) {
4094 $this->repo()->deleteProductGroup((int)($_POST['id'] ?? 0));
4095 } elseif ($this->posted('save_product_group')) {
4096 $this->repo()->updateProductGroup((int)($_POST['id'] ?? 0), $_POST);
4097 }
4098
4099 $cardHtml = function (array $group, bool $isNew = false): string {
4100 $id = (int)($group['id'] ?? 0);
4101 if ($isNew) {
4102 $title = '<span>Neue Artikelgruppe</span>';
4103 $subtitle = 'Key, Name, MwSt.-Klasse und Darstellung festlegen.';
4104 } else {
4105 $title = '<code>' . $this->h($group['group_key'] ?? '') . '</code><span>' . $this->h($group['title'] ?? '') . '</span>';
4106 $subtitle = trim((string)($group['description'] ?? ''));
4107 }
4108
4109 $form = $this->shopAdminCardForm(
4110 'shop-product-group-' . ($isNew ? 'new' : $id),
4111 'dbxShop|shopProductGroup',
4112 $group,
4113 $id,
4114 '?dbx_modul=dbxShop_admin&dbx_run1=groups' . ($isNew ? '&new=1' : ''),
4115 'save_product_group',
4116 'save_product_group',
4117 $title,
4118 $subtitle,
4119 'dbx-shop-product-group-card'
4120 );
4121 $form->add_rep('card_badges', $isNew ? '<span class="badge text-bg-primary">Neu</span>' : $this->activeBadge($group));
4122 $form->add_rep('extra_hidden', '<input type="hidden" name="display_variant" value="' . $this->h($group['display_variant'] ?? 'gallery_grid') . '">');
4123 if (!$isNew) {
4124 $form->add_rep('delete_button', $this->shopAdminCardDeleteButton('delete_product_group', 'Artikelgruppe loeschen', 'Diese Artikelgruppe wirklich loeschen?'));
4125 }
4126 if ($isNew) {
4127 $form->add_fld('group_key', tpl: 'text-label', label: 'Key', placeholder: 'artikelgruppe-key', rules: '*|parameter|max=80');
4128 }
4129 $form->add_fld('parent_id', tpl: 'select-single-label', label: 'Obergruppe', options: $this->productGroupOptions($id, true), rules: 'int');
4130 $form->add_fld('title', tpl: 'text-label', label: 'Gruppe', placeholder: 'Neue Artikelgruppe', rules: '*|max=160');
4131 $form->add_fld('description', tpl: 'textarea-label', label: 'Beschreibung', placeholder: 'Beschreibung', data: 'rows=2');
4132 $form->add_fld('tax_class', tpl: 'select-single-label', label: 'MwSt.-Klasse', options: 'mwst1=mwst1&mwst2=mwst2&mwst3=mwst3');
4133 $form->add_fld('card_template', tpl: 'select-single-label', label: 'Karte', options: 'product-card-default=Standardkarte&product-card-compact=Kompaktkarte');
4134 $form->add_fld('detail_template', tpl: 'select-single-label', label: 'Detail', options: 'product-detail-default=Standarddetail&product-detail-technical=Technische Ansicht');
4135 $form->add_fld('gallery_template', tpl: 'select-single-label', label: 'Gallery', options: 'image-gallery=Bild Gallery&file-gallery=Datei Gallery');
4136 $form->add_fld('gallery_visible_count', tpl: 'text-label', label: 'Bilder', rules: 'int');
4137 $form->add_fld('gallery_image_size', tpl: 'select-single-label', label: 'Bildgroesse', options: 'original=Original&cover=Cover&contain=Contain');
4138 $form->add_fld('gallery_lightbox_width', tpl: 'text-label', label: 'Lightbox', placeholder: '100vw');
4139 $form->add_fld('gallery_overflow', tpl: 'select-single-label', label: 'Overflow', options: 'grid=Grid&slider=Slider&scroll=Scroll&laufband=Laufband&tutorial=Tutorial');
4140 $form->add_fld('gallery_click', tpl: 'select-single-label', label: 'Klick', options: 'lightbox=Lightbox&none=Kein Klick&newtab=Neuer Tab&viewerjs=ViewerJS&photoswipe=PhotoSwipe');
4141 $form->add_fld('attribute_notes', tpl: 'textarea-label', label: 'Artikelattribute', placeholder: 'Attribut-Hinweise', data: 'rows=2');
4142 $channelDefaults = '';
4143 if ($this->channelsEnabled()) {
4144 $form->add_fld('ebay_category_id', tpl: 'text-label', label: 'eBay Kategorie', placeholder: '58058');
4145 $form->add_fld('amazon_product_type', tpl: 'text-label', label: 'Amazon Product Type', placeholder: 'SOFTWARE / PRODUCT / SHIRT');
4146 $form->add_fld('kleinanzeigen_category_id', tpl: 'text-label', label: 'Kleinanzeigen Kategorie', placeholder: 'category_12345');
4147 $form->add_fld('mobile_category_id', tpl: 'text-label', label: 'mobile.de Kategorie', placeholder: 'car');
4148 $channelDefaults = '<div class="wide dbx-shop-channel-defaults">'
4149 . '<h6>Channel-Vorgaben</h6>'
4150 . '<p>Diese Werte dienen als Standard fuer Artikel dieser Gruppe. In der Artikel-Channel-Maske koennen sie pro Artikel ueberschrieben werden.</p>'
4151 . '<div class="dbx-shop-admin-card-grid dbx-shop-channel-default-grid">'
4152 . '<div>{obj:ebay_category_id}</div>'
4153 . '<div>{obj:amazon_product_type}</div>'
4154 . '<div>{obj:kleinanzeigen_category_id}</div>'
4155 . '<div>{obj:mobile_category_id}</div>'
4156 . '</div>'
4157 . '</div>';
4158 }
4159 $form->add_fld('sorter', tpl: 'text-label', label: 'Sort', rules: 'int');
4160 $form->add_fld('active', tpl: 'checkbox-label', label: 'Aktiv', rules: 'int');
4161 $groupImagePanel = $this->productGroupImagePanel($group, $isNew);
4162 $form->add_rep('form_body',
4163 '<div class="dbx-shop-admin-card-grid">'
4164 . ($isNew ? '<div>{obj:group_key}</div>' : '')
4165 . '<div>{obj:parent_id}</div>'
4166 . '<div>{obj:title}</div>'
4167 . '<div>{obj:tax_class}</div>'
4168 . '<div>{obj:sorter}</div>'
4169 . '<div>{obj:active}</div>'
4170 . '<div class="wide">' . $groupImagePanel . '</div>'
4171 . '<div class="wide">{obj:description}</div>'
4172 . '<div>{obj:card_template}</div>'
4173 . '<div>{obj:detail_template}</div>'
4174 . '<div>{obj:gallery_template}</div>'
4175 . '<div>{obj:gallery_visible_count}</div>'
4176 . '<div>{obj:gallery_image_size}</div>'
4177 . '<div>{obj:gallery_lightbox_width}</div>'
4178 . '<div>{obj:gallery_overflow}</div>'
4179 . '<div>{obj:gallery_click}</div>'
4180 . '<div class="wide">{obj:attribute_notes}</div>'
4181 . $channelDefaults
4182 . '</div>'
4183 );
4184 return $form->run();
4185 };
4186
4187 $groups = $this->repo()->groups();
4188 $cards = '';
4189 if ((int)($_GET['new'] ?? 0) === 1) {
4190 $sorter = 10;
4191 foreach ($groups as $group) {
4192 $sorter = max($sorter, (int)($group['sorter'] ?? 0) + 10);
4193 }
4194 $cards .= $cardHtml(array(
4195 'tax_class' => (string)($this->shopConfig()['default_tax_class'] ?? 'mwst1'),
4196 'default_tax_rate' => 19,
4197 'parent_id' => 0,
4198 'display_variant' => 'gallery_grid',
4199 'card_template' => 'product-card-default',
4200 'detail_template' => 'product-detail-default',
4201 'gallery_template' => 'image-gallery',
4202 'gallery_visible_count' => 3,
4203 'gallery_image_size' => 'original',
4204 'gallery_lightbox_width' => '100vw',
4205 'gallery_overflow' => 'grid',
4206 'gallery_click' => 'lightbox',
4207 'active' => 1,
4208 'sorter' => $sorter,
4209 ), true);
4210 }
4211 foreach ($groups as $group) {
4212 $cards .= $cardHtml($group);
4213 }
4214 $helpButton = $this->helpButton($this->ensureShopProductGroupsHelpPage(), 'Hilfe: Artikelgruppen');
4215 $barActions = '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=groups&new=1" title="Neue Artikelgruppe anlegen"><i class="bi bi-plus-square"></i><span class="visually-hidden"> Neue Artikelgruppe</span></a>' . $helpButton;
4216 return $this->frame('<div class="alert alert-info mx-3 mt-3 mb-0">Artikelgruppen steuern MwSt.-Klasse, Darstellung, Gallery und Attribute. Artikel waehlen nur die Gruppe.</div><div class="dbx-shop-admin-card-list">' . $cards . '</div>', 'Artikelgruppen', $barActions);
4217 }
4218
4219 private function attributes(): string {
4220 $this->ensureSeed();
4221 if ($this->posted('save_attribute_definition')) {
4222 $this->repo()->saveAttributeDefinition($_POST);
4223 }
4224
4225 $groupOptions = array();
4226 foreach ($this->repo()->groups() as $group) {
4227 $groupOptions[(string)(int)($group['id'] ?? 0)] = (string)($group['title'] ?? '');
4228 }
4229
4230 $cardHelpButton = $this->helpButton($this->ensureShopProductAttributesHelpPage(), 'Hilfe: Artikelattribute', 'btn btn-outline-secondary btn-sm me-1');
4231 $cardHtml = function (array $attribute, bool $isNew = false) use ($groupOptions, $cardHelpButton): string {
4232 $id = (int)($attribute['id'] ?? 0);
4233 $type = (string)($attribute['input_type'] ?? 'text');
4234 $title = $isNew
4235 ? '<span>Neues Attribut</span>'
4236 : '<code>' . $this->h($attribute['attr_key'] ?? '') . '</code><span>' . $this->h($attribute['title'] ?? '') . '</span>';
4237 $subtitle = $isNew ? 'Attribut fuer eine Artikelgruppe anlegen.' : (string)($attribute['group_title'] ?? '');
4238
4239 $form = $this->shopAdminCardForm(
4240 'shop-attribute-definition-' . ($isNew ? 'new' : $id),
4241 'dbxShop|shopAttributeDefinition',
4242 $attribute,
4243 $id,
4244 '?dbx_modul=dbxShop_admin&dbx_run1=attributes',
4245 'save_attribute_definition',
4246 'save_attribute_definition',
4247 $title,
4248 $subtitle,
4249 'dbx-shop-attribute-card'
4250 );
4251 $form->add_rep('card_badges', $cardHelpButton . ($isNew ? '<span class="badge text-bg-primary">Neu</span>' : $this->activeBadge($attribute)));
4252 $form->add_fld('group_id', tpl: 'select-single-label', label: 'Gruppe', options: $groupOptions, rules: 'int');
4253 $form->add_fld('attr_key', tpl: 'text-label', label: 'Key', placeholder: 'farbe', rules: '*|parameter|max=80');
4254 $form->add_fld('title', tpl: 'text-label', label: 'Titel', placeholder: 'Farbe', rules: '*|max=160');
4255 $form->add_fld('input_type', tpl: 'select-single-label', label: 'Typ', options: 'text=Text&select=Auswahlliste&number=Zahl');
4256 $form->add_fld('unit', tpl: 'text-label', label: 'Einheit', placeholder: 'cm');
4257 $form->add_fld('options', tpl: 'textarea-label', label: 'Optionen', placeholder: 'S|M|L|XL', data: 'rows=2');
4258 $form->add_fld('required', tpl: 'checkbox-label', label: 'Pflicht', rules: 'int');
4259 $form->add_fld('filterable', tpl: 'checkbox-label', label: 'Filter', rules: 'int');
4260 $form->add_fld('comparable', tpl: 'checkbox-label', label: 'Vergleich', rules: 'int');
4261 $form->add_fld('sorter', tpl: 'text-label', label: 'Sort', rules: 'int');
4262 $form->add_fld('active', tpl: 'checkbox-label', label: 'Aktiv', rules: 'int');
4263 $form->add_rep('form_body',
4264 '<div class="dbx-shop-admin-card-grid">'
4265 . '<div>{obj:group_id}</div>'
4266 . '<div>{obj:attr_key}</div>'
4267 . '<div>{obj:title}</div>'
4268 . '<div>{obj:input_type}</div>'
4269 . '<div>{obj:unit}</div>'
4270 . '<div>{obj:sorter}</div>'
4271 . '<div class="wide">{obj:options}</div>'
4272 . '<div class="wide dbx-shop-admin-check-grid">{obj:required}{obj:filterable}{obj:comparable}{obj:active}</div>'
4273 . '</div>'
4274 );
4275 return $form->run();
4276 };
4277
4278 $cards = $cardHtml(array(
4279 'group_id' => (int)array_key_first($groupOptions),
4280 'input_type' => 'text',
4281 'required' => 0,
4282 'filterable' => 1,
4283 'comparable' => 0,
4284 'active' => 1,
4285 'sorter' => 100,
4286 ), true);
4287 foreach ($this->repo()->allAttributeDefinitions() as $attribute) {
4288 $cards .= $cardHtml($attribute);
4289 }
4290
4291 $barActions = $this->helpButton($this->ensureShopProductAttributesHelpPage(), 'Hilfe: Artikelattribute');
4292 return $this->frame('<div class="alert alert-info mx-3 mt-3 mb-0">Artikelattribute definieren gruppenspezifische Eingabefelder wie Farbe, Groesse, Material oder technische Werte.</div><div class="dbx-shop-admin-card-list">' . $cards . '</div>', 'Artikelattribute', $barActions);
4293 }
4294
4295 private function productAttributes(): string {
4296 $this->ensureSeed();
4297 $productId = (int)dbx()->get_modul_var('id', '0', 'int');
4298 if ($this->posted('save_product_attributes')) {
4299 $productId = (int)($_POST['product_id'] ?? $productId);
4300 $this->repo()->saveProductAttributeValues($productId, $_POST['attr_value'] ?? array());
4301 }
4302 $product = $this->repo()->productById($productId);
4303 if (!$product) {
4304 return $this->placeholder('Artikelattribute', 'Artikel wurde nicht gefunden.');
4305 }
4306
4307 $valueMap = array();
4308 foreach (($product['attributes'] ?? array()) as $attribute) {
4309 $valueMap[(int)($attribute['id'] ?? 0)] = (string)($attribute['value_text'] ?? '');
4310 }
4311
4312 $rows = '';
4313 foreach ($this->repo()->attributeDefinitionsForProduct($productId, true) as $definition) {
4314 $id = (int)($definition['id'] ?? 0);
4315 $value = $valueMap[$id] ?? '';
4316 $type = (string)($definition['input_type'] ?? 'text');
4317 $input = '';
4318 if ($type === 'select') {
4319 $input = '<select class="form-select form-select-sm" name="attr_value[' . $id . ']"><option value="">-</option>';
4320 foreach ($this->attributeOptions((string)($definition['options'] ?? '')) as $option) {
4321 $input .= '<option value="' . $this->h($option) . '"' . ($option === $value ? ' selected' : '') . '>' . $this->h($option) . '</option>';
4322 }
4323 if ($value !== '' && strpos((string)($definition['options'] ?? ''), $value) === false) {
4324 $input .= '<option value="' . $this->h($value) . '" selected>' . $this->h($value) . '</option>';
4325 }
4326 $input .= '</select>';
4327 } else {
4328 $inputType = $type === 'number' ? 'number' : 'text';
4329 $step = $type === 'number' ? ' step="0.01"' : '';
4330 $input = '<input class="form-control form-control-sm" type="' . $inputType . '"' . $step . ' name="attr_value[' . $id . ']" value="' . $this->h($value) . '">';
4331 }
4332 $rows .= '<tr>';
4333 $rows .= '<td><strong>' . $this->h($definition['title'] ?? '') . '</strong><br><small><code>' . $this->h($definition['attr_key'] ?? '') . '</code></small></td>';
4334 $rows .= '<td>' . $input . '</td>';
4335 $rows .= '<td>' . $this->h($definition['unit'] ?? '') . '</td>';
4336 $rows .= '<td>' . ((int)($definition['required'] ?? 0) === 1 ? '<span class="badge text-bg-warning">Pflicht</span>' : '') . ' ' . ((int)($definition['filterable'] ?? 0) === 1 ? '<span class="badge text-bg-info">Filter</span>' : '') . '</td>';
4337 $rows .= '</tr>';
4338 }
4339
4340 if ($rows === '') {
4341 $rows = '<tr><td colspan="4" class="text-muted">Fuer die Artikelgruppe sind noch keine Attribute definiert.</td></tr>';
4342 }
4343
4344 $form = $this->shopAdminCardForm(
4345 'shop-product-attributes-' . $productId,
4346 'dbxShop|shopProductAttributeValue',
4347 array('id' => $productId),
4348 $productId,
4349 '?dbx_modul=dbxShop_admin&dbx_run1=product_attributes&id=' . $productId,
4350 'save_product_attributes',
4351 'save_product_attributes',
4352 '<code>' . $this->h($product['sku'] ?? '') . '</code><span>' . $this->h($product['title'] ?? '') . '</span>',
4353 'Attributwerte dieses Artikels bearbeiten.',
4354 'dbx-shop-product-attributes-card'
4355 );
4356 $form->add_rep('extra_hidden', '<input type="hidden" name="product_id" value="' . $productId . '">');
4357 $form->add_rep('card_badges', $this->helpButton($this->ensureShopProductAttributesHelpPage(), 'Hilfe: Artikelattribute', 'btn btn-outline-secondary btn-sm me-1') . '<a class="btn btn-outline-secondary btn-sm" href="?dbx_modul=dbxShop_admin&dbx_run1=products">Zurueck</a>');
4358 $form->add_rep('form_body', '<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Attribut</th><th>Wert</th><th>Einheit</th><th>Eigenschaft</th></tr></thead><tbody>' . $rows . '</tbody></table></div>');
4359 return $this->frame('<div class="dbx-shop-admin-card-list">' . $form->run() . '</div>', 'Artikelattribute bearbeiten');
4360 }
4361
4362 private function shippingGroups(): string {
4363 $this->ensureSeed();
4364 if ($this->posted('delete_shipping_group')) {
4365 $this->repo()->deleteShippingGroup((int)($_POST['id'] ?? 0));
4366 } elseif ($this->posted('save_shipping_group')) {
4367 $this->repo()->updateShippingGroup((int)($_POST['id'] ?? 0), $_POST);
4368 }
4369
4370 $cardHelpButton = $this->helpButton($this->ensureShopShippingGroupsHelpPage(), 'Hilfe: Versandgruppen', 'btn btn-outline-secondary btn-sm me-1');
4371 $cardHtml = function (array $group, bool $isNew = false) use ($cardHelpButton): string {
4372 $id = (int)($group['id'] ?? 0);
4373 if ($isNew) {
4374 $title = '<span>Neue Versandgruppe</span>';
4375 $subtitle = 'Versandweg, Lieferzeit, Kosten und Sortierung festlegen.';
4376 } else {
4377 $title = '<code>' . $this->h($group['group_key'] ?? '') . '</code><span>' . $this->h($group['title'] ?? '') . '</span>';
4378 $subtitle = trim((string)($group['description'] ?? ''));
4379 }
4380
4381 $form = $this->shopAdminCardForm(
4382 'shop-shipping-group-' . ($isNew ? 'new' : $id),
4383 'dbxShop|shopShippingGroup',
4384 $group,
4385 $id,
4386 '?dbx_modul=dbxShop_admin&dbx_run1=shipping_groups' . ($isNew ? '&new=1' : ''),
4387 'save_shipping_group',
4388 'save_shipping_group',
4389 $title,
4390 $subtitle,
4391 'dbx-shop-shipping-group-card'
4392 );
4393 $form->add_rep('card_badges', $cardHelpButton . ($isNew ? '<span class="badge text-bg-primary">Neu</span>' : $this->activeBadge($group)));
4394 if (!$isNew) {
4395 $form->add_rep('delete_button', $this->shopAdminCardDeleteButton('delete_shipping_group', 'Versandgruppe loeschen', 'Diese Versandgruppe wirklich loeschen?'));
4396 }
4397 if ($isNew) {
4398 $form->add_fld('group_key', tpl: 'text-label', label: 'Key', placeholder: 'versandgruppe-key', rules: '*|parameter|max=80');
4399 }
4400 $form->add_fld('title', tpl: 'text-label', label: 'Versandgruppe', placeholder: 'Neue Versandgruppe', rules: '*|max=160');
4401 $form->add_fld('description', tpl: 'textarea-label', label: 'Beschreibung', placeholder: 'Beschreibung', data: 'rows=2');
4402 $form->add_fld('shipping_way', tpl: 'text-label', label: 'Versandweg', placeholder: 'Paketdienst');
4403 $form->add_fld('delivery_time', tpl: 'text-label', label: 'Lieferzeit', placeholder: '1-3 Werktage');
4404 $form->add_fld('shipping_gross', tpl: 'text-label', label: 'Kosten brutto', placeholder: '5.90', rules: 'decimal');
4405 $form->add_fld('free_from_gross', tpl: 'text-label', label: 'Frei ab brutto', placeholder: '-1', rules: 'decimal');
4406 $form->add_fld('sorter', tpl: 'text-label', label: 'Sort', rules: 'int');
4407 $form->add_fld('active', tpl: 'checkbox-label', label: 'Aktiv', rules: 'int');
4408 $form->add_rep('form_body',
4409 '<div class="dbx-shop-admin-card-grid">'
4410 . ($isNew ? '<div>{obj:group_key}</div>' : '')
4411 . '<div>{obj:title}</div>'
4412 . '<div>{obj:shipping_way}</div>'
4413 . '<div>{obj:delivery_time}</div>'
4414 . '<div>{obj:shipping_gross}</div>'
4415 . '<div>{obj:free_from_gross}</div>'
4416 . '<div>{obj:sorter}</div>'
4417 . '<div>{obj:active}</div>'
4418 . '<div class="wide">{obj:description}</div>'
4419 . '</div>'
4420 );
4421 return $form->run();
4422 };
4423
4424 $groups = $this->repo()->shippingGroups();
4425 $cards = '';
4426 if ((int)($_GET['new'] ?? 0) === 1) {
4427 $sorter = 10;
4428 foreach ($groups as $group) {
4429 $sorter = max($sorter, (int)($group['sorter'] ?? 0) + 10);
4430 }
4431 $cards .= $cardHtml(array(
4432 'shipping_gross' => 0,
4433 'free_from_gross' => -1,
4434 'active' => 1,
4435 'sorter' => $sorter,
4436 ), true);
4437 }
4438 foreach ($groups as $group) {
4439 $cards .= $cardHtml($group);
4440 }
4441 $helpButton = $this->helpButton($this->ensureShopShippingGroupsHelpPage(), 'Hilfe: Versandgruppen');
4442 $barActions = '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=shipping_groups&new=1" title="Neue Versandgruppe anlegen"><i class="bi bi-plus-square"></i><span class="visually-hidden"> Neue Versandgruppe</span></a>' . $helpButton;
4443 return $this->frame('<div class="alert alert-info mx-3 mt-3 mb-0">Versandgruppen beschreiben Lieferart, Lieferzeit und Kosten. Artikel koennen diese Gruppe nutzen oder eigene Versandwerte ueberschreiben.</div><div class="dbx-shop-admin-card-list">' . $cards . '</div>', 'Versandgruppen', $barActions);
4444 }
4445
4446 private function channelGroups(): string {
4447 $this->ensureSeed();
4448 if ($this->posted('delete_channel_group')) {
4449 $this->repo()->deleteChannelGroup((int)($_POST['id'] ?? 0));
4450 } elseif ($this->posted('save_channel_group')) {
4451 $this->repo()->updateChannelGroup((int)($_POST['id'] ?? 0), $_POST, array_map('strval', $_POST['channels'] ?? array()));
4452 }
4453 $channels = $this->repo()->channels();
4454 $groups = $this->repo()->channelGroups();
4455 $cardHelpButton = $this->helpButton($this->ensureShopChannelHelpPage(), 'Hilfe: Channel-Gruppen', 'btn btn-outline-secondary btn-sm me-1');
4456 $cardHtml = function (array $group, bool $isNew = false) use ($channels, $cardHelpButton): string {
4457 $id = (int)($group['id'] ?? 0);
4458 $active = array();
4459 foreach (($group['channels'] ?? array()) as $channel) {
4460 if ((int)($channel['active'] ?? 0) === 1) {
4461 $active[] = (string)$channel['channel_key'];
4462 }
4463 }
4464 $checks = '<div class="dbx-shop-admin-check-grid">';
4465 foreach ($channels as $channel) {
4466 $key = (string)($channel['channel_key'] ?? '');
4467 if ($key === '') {
4468 continue;
4469 }
4470 $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>';
4471 }
4472 $checks .= '</div>';
4473
4474 if ($isNew) {
4475 $title = '<span>Neue Channel-Gruppe</span>';
4476 $subtitle = 'Channels zusammenfassen, damit Artikel sie als Gruppe nutzen koennen.';
4477 } else {
4478 $title = '<code>' . $this->h($group['group_key'] ?? '') . '</code><span>' . $this->h($group['title'] ?? '') . '</span>';
4479 $subtitle = trim((string)($group['description'] ?? ''));
4480 }
4481
4482 $form = $this->shopAdminCardForm(
4483 'shop-channel-group-' . ($isNew ? 'new' : $id),
4484 'dbxShop|shopChannelGroup',
4485 $group,
4486 $id,
4487 '?dbx_modul=dbxShop_admin&dbx_run1=channel_groups' . ($isNew ? '&new=1' : ''),
4488 'save_channel_group',
4489 'save_channel_group',
4490 $title,
4491 $subtitle,
4492 'dbx-shop-channel-group-card'
4493 );
4494 $form->add_rep('card_badges', $cardHelpButton . ($isNew ? '<span class="badge text-bg-primary">Neu</span>' : $this->activeBadge($group)));
4495 if (!$isNew) {
4496 $form->add_rep('delete_button', $this->shopAdminCardDeleteButton('delete_channel_group', 'Channel-Gruppe loeschen', 'Diese Channel-Gruppe wirklich loeschen?'));
4497 }
4498 if ($isNew) {
4499 $form->add_fld('group_key', tpl: 'text-label', label: 'Key', placeholder: 'neue-channel-gruppe', rules: '*|parameter|max=80');
4500 }
4501 $form->add_fld('title', tpl: 'text-label', label: 'Channel-Gruppe', placeholder: 'Neue Channel-Gruppe', rules: '*|max=160');
4502 $form->add_fld('description', tpl: 'textarea-label', label: 'Beschreibung', placeholder: 'Beschreibung', data: 'rows=2');
4503 $form->add_fld('sorter', tpl: 'text-label', label: 'Sort', rules: 'int');
4504 $form->add_fld('active', tpl: 'checkbox-label', label: 'Aktiv', rules: 'int');
4505 $form->add_obj('channel_checks', 'obj-value', $checks);
4506 $form->add_rep('form_body',
4507 '<div class="dbx-shop-admin-card-grid">'
4508 . ($isNew ? '<div>{obj:group_key}</div>' : '')
4509 . '<div>{obj:title}</div>'
4510 . '<div>{obj:sorter}</div>'
4511 . '<div>{obj:active}</div>'
4512 . '<div class="wide">{obj:description}</div>'
4513 . '<div class="wide"><label class="form-label">Channels</label>{obj:channel_checks}</div>'
4514 . '</div>'
4515 );
4516 return $form->run();
4517 };
4518
4519 $cards = '';
4520 if ((int)($_GET['new'] ?? 0) === 1) {
4521 $sorter = 10;
4522 foreach ($groups as $group) {
4523 $sorter = max($sorter, (int)($group['sorter'] ?? 0) + 10);
4524 }
4525 $cards .= $cardHtml(array(
4526 'title' => '',
4527 'description' => '',
4528 'active' => 1,
4529 'sorter' => $sorter,
4530 'channels' => array(),
4531 ), true);
4532 }
4533 foreach ($groups as $group) {
4534 $cards .= $cardHtml($group);
4535 }
4536 $helpButton = $this->helpButton($this->ensureShopChannelHelpPage(), 'Hilfe: Channel-Gruppen');
4537 $barActions = '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=channel_groups&new=1" title="Neue Channel-Gruppe anlegen"><i class="bi bi-plus-square"></i><span class="visually-hidden"> Neue Channel-Gruppe</span></a>' . $helpButton;
4538 return $this->frame('<div class="alert alert-info mx-3 mt-3 mb-0">Channel-Gruppen buendeln Verkaufskanaele. Artikel koennen diese Gruppe erben und bei Bedarf eigene Channel-Ausnahmen setzen.</div><div class="dbx-shop-admin-card-list">' . $cards . '</div>', 'Channel-Gruppen', $barActions);
4539 }
4540
4541 private function shopMediaDir(): string {
4542 $dir = dirname(__DIR__, 4) . '/files/shop/img';
4543 if (!is_dir($dir)) {
4544 mkdir($dir, 0775, true);
4545 }
4546 return $dir;
4547 }
4548
4549 private function safeFileName(string $name): string {
4550 $name = strtolower(trim($name));
4551 $name = preg_replace('~[^a-z0-9._-]+~', '-', $name);
4552 $name = trim((string)$name, '-.');
4553 return $name !== '' ? $name : 'shop-image';
4554 }
4555
4556 private function handleMediaUpload(): string {
4557 if (!$this->posted('upload_media')) {
4558 return '';
4559 }
4560 if (empty($_FILES['shop_image']['tmp_name']) || !is_uploaded_file($_FILES['shop_image']['tmp_name'])) {
4561 return '<div class="alert alert-warning m-3">Keine Datei ausgewaehlt.</div>';
4562 }
4563 $original = (string)($_FILES['shop_image']['name'] ?? 'shop-image');
4564 $ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
4565 $allowed = array('jpg','jpeg','png','gif','webp','svg');
4566 if (!in_array($ext, $allowed, true)) {
4567 return '<div class="alert alert-danger m-3">Dieser Bildtyp ist fuer Shop-Medien nicht erlaubt.</div>';
4568 }
4569
4570 $base = $this->safeFileName(pathinfo($original, PATHINFO_FILENAME));
4571 $name = $base . '.' . $ext;
4572 $target = $this->shopMediaDir() . '/' . $name;
4573 $i = 2;
4574 while (is_file($target)) {
4575 $name = $base . '-' . $i . '.' . $ext;
4576 $target = $this->shopMediaDir() . '/' . $name;
4577 $i++;
4578 }
4579 if (!move_uploaded_file($_FILES['shop_image']['tmp_name'], $target)) {
4580 return '<div class="alert alert-danger m-3">Upload konnte nicht gespeichert werden.</div>';
4581 }
4582
4583 $rel = 'files/shop/img/' . $name;
4584 $productId = (int)($_POST['product_id'] ?? 0);
4585 $groupId = (int)($_POST['group_id'] ?? 0);
4586 if ($productId > 0 || $groupId > 0) {
4587 $this->repo()->saveImage($productId, $groupId, $rel, (string)($_POST['title'] ?? $base), (string)($_POST['alt'] ?? $base), !empty($_POST['is_primary']) ? 1 : 0, (int)($_POST['sorter'] ?? 100));
4588 }
4589 return '<div class="alert alert-success m-3">Bild wurde hochgeladen: ' . $this->h($name) . '</div>';
4590 }
4591
4592 private function media(): string {
4593 $this->ensureSeed();
4594 $this->ensureCmsShopMediaFolder();
4595 $this->syncShopMediaUsage();
4596 $selectedProduct = (int)dbx()->get_modul_var('product_id', '0', 'int');
4597 $selectedGroup = (int)dbx()->get_modul_var('group_id', '0', 'int');
4598
4599 $productOptions = '<option value="0">Kein einzelner Artikel</option>';
4600 foreach ($this->repo()->products(false) as $product) {
4601 $sel = (int)($product['id'] ?? 0) === $selectedProduct ? ' selected' : '';
4602 $productOptions .= '<option value="' . (int)($product['id'] ?? 0) . '"' . $sel . '>' . $this->h($product['title'] ?? '') . '</option>';
4603 }
4604 $groupOptions = '<option value="0">Keine Artikelgruppe</option>';
4605 foreach ($this->repo()->groups() as $group) {
4606 $sel = (int)($group['id'] ?? 0) === $selectedGroup ? ' selected' : '';
4607 $groupOptions .= '<option value="' . (int)($group['id'] ?? 0) . '"' . $sel . '>' . $this->h($group['title'] ?? '') . '</option>';
4608 }
4609
4610 $mediaCfg = $this->shopMediaConfig();
4611 $attrs = $this->shopMediaAttrs($mediaCfg);
4612
4613 $html = '<div class="dbx-shop-media-manager m-3"' . $attrs . '>';
4614 $targetForm = dbx()->get_system_obj('dbxForm');
4615 $targetForm->init('shop-media-target-form', 'shop-media-target-form');
4616 $targetForm->_action = '?dbx_modul=dbxShop_admin&dbx_run1=media';
4617 $targetForm->add_rep('frame_skip_form_wrap', '1');
4618 $targetForm->add_rep('frame_form_open', '<form action="?dbx_modul=dbxShop_admin&dbx_run1=media" method="get" class="dbx-shop-media-target-filter">');
4619 $targetForm->add_rep('frame_form_close', '</form>');
4620 $targetForm->add_rep('form_body',
4621 '<div class="row g-2 align-items-end">'
4622 . '<div class="col-md-4"><label class="form-label">Artikel</label><select class="form-select form-select-sm" name="product_id" data-shop-product-select>' . $productOptions . '</select></div>'
4623 . '<div class="col-md-4"><label class="form-label">Artikelgruppe</label><select class="form-select form-select-sm" name="group_id" data-shop-group-select>' . $groupOptions . '</select></div>'
4624 . '<div class="col-md-2"><label class="form-label">Sortierung</label><input class="form-control form-control-sm" name="sorter" value="100" data-shop-sorter></div>'
4625 . '<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">Primaerbild</label></div>'
4626 . '<div class="col-md-8"><div class="form-text">Bilder werden im vorhandenen CMS-Medienbrowser unter <code>files/media/img/shop/</code> verwaltet. Dort sind Upload, Zuschneiden, Resize, Batch und Wartung verfuegbar.</div></div>'
4627 . '<div class="col-md-2"><button class="btn btn-outline-secondary btn-sm w-100"><i class="bi bi-filter"></i> Auswahl laden</button></div>'
4628 . '<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="Bilder aus dem Medienbrowser zuordnen"><i class="bi bi-images"></i><i class="bi bi-camera-video"></i><i class="bi bi-upload"></i><span>Auswahl</span></button></div>'
4629 . '</div>'
4630 );
4631 $html .= $targetForm->run();
4632
4633 $assigned = '';
4634 foreach ($this->repo()->allImages() as $image) {
4635 $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'] ?: 'nicht zugeordnet') . '</small></div></div></div>';
4636 }
4637 $html .= '<div class="m-3"><h5>Zugeordnete Shop-Bilder</h5><div class="row row-cols-2 row-cols-md-4 row-cols-xl-6 g-2">' . $assigned . '</div></div>';
4638
4639 $files = glob($this->shopMediaDir() . '/*.{jpg,jpeg,png,gif,webp,svg}', GLOB_BRACE) ?: array();
4640 $fileCards = '';
4641 foreach ($files as $file) {
4642 $rel = 'files/shop/img/' . basename($file);
4643 $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>';
4644 }
4645 $html .= '<div class="m-3"><h5>Alte Fallback-Dateien in files/shop/img</h5><div class="row row-cols-2 row-cols-md-4 row-cols-xl-6 g-2">' . $fileCards . '</div></div>';
4646 $html .= '</div>';
4647
4648 $barActions = $this->helpButton($this->ensureShopMediaHelpPage(), 'Hilfe: Shop-Medien');
4649 return $this->frame($html, 'Shop-Medien', $barActions);
4650 }
4651
4652 private function channels(): string {
4653 $this->ensureSeed();
4654 $notice = '';
4655 if ($this->posted('delete_channel')) {
4656 $this->repo()->deleteChannel((int)($_POST['id'] ?? 0));
4657 } elseif ($this->posted('save_channel')) {
4658 $this->repo()->updateChannel((int)($_POST['id'] ?? 0), $_POST);
4659 } elseif ($this->posted('test_channel')) {
4660 $id = (int)($_POST['id'] ?? 0);
4661 if ($id > 0) {
4662 $this->repo()->updateChannel($id, $_POST);
4663 $result = $this->repo()->testChannelConnection($id);
4664 $notice = '<div class="alert ' . (!empty($result['ok']) ? 'alert-success' : 'alert-warning') . ' m-3">' . $this->h($result['message'] ?? '') . '</div>';
4665 } else {
4666 $notice = '<div class="alert alert-warning m-3">Neuen Channel bitte zuerst speichern, danach kann die Verbindung getestet werden.</div>';
4667 }
4668 }
4669
4670 $platformOptions = array(
4671 'shop' => 'Shop',
4672 'amazon' => 'Amazon',
4673 'ebay' => 'eBay',
4674 'kleinanzeigen' => 'Kleinanzeigen',
4675 'mobile' => 'mobile.de',
4676 'custom' => 'Eigener Channel',
4677 );
4678 $modeOptions = array(
4679 'internal' => 'Intern',
4680 'manual' => 'Manuell',
4681 'api' => 'API',
4682 'feed' => 'Feed',
4683 'webhook' => 'Webhook',
4684 );
4685
4686 $platformHints = array(
4687 'shop' => array('Interner Shop-Channel ohne externe API.', 'Keine externen Zugangsdaten notwendig.', 'Bestellungen entstehen direkt im Shop.'),
4688 'amazon' => array('Amazon SP-API Zugangsdaten und Seller-Kontext pflegen.', 'Listings laufen ueber Amazon Produktschemas und Marketplace-Daten.', 'Rueckmeldungen kommen ueber Orders/Notifications, meist per SQS.'),
4689 'ebay' => array('eBay Sell API Zugangsdaten und OAuth-Scopes pflegen.', 'Listings brauchen Marketplace, Kategorie, Standort und Business Policy IDs.', 'Rueckmeldungen kommen ueber Fulfillment API oder Notification API.'),
4690 'kleinanzeigen' => array('Kleinanzeigen ist hier als manueller oder vertraglicher Channel vorbereitet.', 'Eine allgemein frei nutzbare offizielle Anzeigen-/Order-API ist nicht hinterlegt.', 'Order-Import nur nutzen, wenn eine freigegebene Schnittstelle oder Middleware vorhanden ist.'),
4691 'mobile' => array('mobile.de API-Zugang und Haendlerkennung pflegen.', 'Anzeigen sind Fahrzeug-/Inseratsdaten, nicht klassische Shop-Produkte.', 'Rueckmeldungen kommen eher ueber Lead API oder Direct Offers.'),
4692 'custom' => array('Eigener Channel fuer individuelle Schnittstellen.', 'API-URL, Zugangsdaten und Payload-Mapping muessen zum Adapter passen.', 'Rueckmeldungen koennen den generischen dbxShop-Webhook nutzen.'),
4693 );
4694
4695 $rowHtml = function (array $channel, bool $isNew = false) use ($platformOptions, $modeOptions, $platformHints): string {
4696 $id = (int)($channel['id'] ?? 0);
4697 $key = (string)($channel['channel_key'] ?? '');
4698 $platform = (string)($channel['platform_type'] ?? 'custom');
4699 $hint = $platformHints[$platform] ?? $platformHints['custom'];
4700 $placeholderMap = array(
4701 'shop' => array(
4702 'channel_key' => 'shop',
4703 'api_base_url' => 'nicht benoetigt',
4704 'api_client_id' => 'nicht benoetigt',
4705 'api_username' => 'nicht benoetigt',
4706 'api_client_secret' => 'nicht benoetigt',
4707 'api_access_token' => 'nicht benoetigt',
4708 'api_refresh_token' => 'nicht benoetigt',
4709 'api_password' => 'nicht benoetigt',
4710 'marketplace_id' => 'nicht benoetigt',
4711 'seller_id' => 'nicht benoetigt',
4712 'account_id' => 'nicht benoetigt',
4713 'location_key' => 'nicht benoetigt',
4714 'category_id' => 'nicht benoetigt',
4715 'payment_policy_id' => 'nicht benoetigt',
4716 'fulfillment_policy_id' => 'nicht benoetigt',
4717 'return_policy_id' => 'nicht benoetigt',
4718 'notification_destination' => 'nicht benoetigt',
4719 'notification_topic' => 'nicht benoetigt',
4720 'webhook_secret' => 'nicht benoetigt',
4721 'webhook_url' => 'nicht benoetigt',
4722 'api_scope' => 'nicht benoetigt',
4723 ),
4724 'amazon' => array(
4725 'channel_key' => 'amazon',
4726 'api_base_url' => 'https://sellingpartnerapi-eu.amazon.com',
4727 'api_client_id' => 'amzn1.application-oa2-client.keyxxxx',
4728 'api_username' => 'nicht benoetigt bei OAuth',
4729 'api_client_secret' => 'amzn1.oa2-cs.v1.secretxxxx',
4730 'api_access_token' => 'Atza|access_token_xxxx',
4731 'api_refresh_token' => 'Atzr|refresh_token_xxxx',
4732 'api_password' => 'nicht benoetigt bei SP-API',
4733 'marketplace_id' => 'A1PA6795UKMFR9',
4734 'seller_id' => 'A1SELLERIDXXXX',
4735 'account_id' => 'account_xxxx',
4736 'location_key' => 'nicht benoetigt',
4737 'category_id' => 'productType: SOFTWARE / PRODUCT',
4738 'payment_policy_id' => 'nicht benoetigt',
4739 'fulfillment_policy_id' => 'nicht benoetigt',
4740 'return_policy_id' => 'nicht benoetigt',
4741 'notification_destination' => 'arn:aws:sqs:eu-central-1:123456789012:amazon-orders',
4742 'notification_topic' => 'ORDER_CHANGE',
4743 'webhook_secret' => 'secret_64zeichen_xxxx',
4744 'webhook_url' => 'wird von dbxShop erzeugt',
4745 'api_scope' => "Listings Items\nOrders\nNotifications",
4746 ),
4747 'ebay' => array(
4748 'channel_key' => 'ebay',
4749 'api_base_url' => 'https://api.ebay.com',
4750 'api_client_id' => 'keyxxxx-appid',
4751 'api_username' => 'nicht benoetigt bei OAuth',
4752 'api_client_secret' => 'certid-secretxxxx',
4753 'api_access_token' => 'v^1.1#i^1#p^3#access_token_xxxx',
4754 'api_refresh_token' => 'v^1.1#r^1#p^3#refresh_token_xxxx',
4755 'api_password' => 'nicht benoetigt bei OAuth',
4756 'marketplace_id' => 'EBAY_DE',
4757 'seller_id' => 'sellername_xxxx',
4758 'account_id' => 'account_xxxx',
4759 'location_key' => 'default',
4760 'category_id' => '58058',
4761 'payment_policy_id' => 'policy_payment_1234567890',
4762 'fulfillment_policy_id' => 'policy_fulfillment_1234567890',
4763 'return_policy_id' => 'policy_return_1234567890',
4764 'notification_destination' => 'https://domain.de/?dbx_modul=dbxShop&dbx_run1=channel_webhook&channel=ebay',
4765 'notification_topic' => 'ORDER',
4766 'webhook_secret' => 'verification_token_xxxx',
4767 'webhook_url' => 'wird von dbxShop erzeugt',
4768 '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",
4769 ),
4770 'kleinanzeigen' => array(
4771 'channel_key' => 'kleinanzeigen',
4772 'api_base_url' => 'nur bei freigegebener Schnittstelle',
4773 'api_client_id' => 'partner_key_xxxx',
4774 'api_username' => 'api-user@example.de',
4775 'api_client_secret' => 'partner_secret_xxxx',
4776 'api_access_token' => 'access_token_xxxx',
4777 'api_refresh_token' => 'refresh_token_xxxx',
4778 'api_password' => 'password_xxxx',
4779 'marketplace_id' => 'DE',
4780 'seller_id' => 'seller_xxxx',
4781 'account_id' => 'account_xxxx',
4782 'location_key' => 'standort_10115_berlin',
4783 'category_id' => 'category_12345',
4784 'payment_policy_id' => 'nicht benoetigt',
4785 'fulfillment_policy_id' => 'nicht benoetigt',
4786 'return_policy_id' => 'nicht benoetigt',
4787 'notification_destination' => 'https://middleware.example.de/webhook',
4788 'notification_topic' => 'lead / message / sale',
4789 'webhook_secret' => 'middleware_secret_xxxx',
4790 'webhook_url' => 'wird von dbxShop erzeugt',
4791 'api_scope' => "laut Vertrag\nMiddleware-Berechtigung",
4792 ),
4793 'mobile' => array(
4794 'channel_key' => 'mobile',
4795 'api_base_url' => 'https://services.mobile.de/seller-api',
4796 'api_client_id' => 'nicht benoetigt bei Basic Auth',
4797 'api_username' => 'dealer_api_user_xxxx',
4798 'api_client_secret' => 'nicht benoetigt bei Basic Auth',
4799 'api_access_token' => 'nicht benoetigt bei Basic Auth',
4800 'api_refresh_token' => 'nicht benoetigt bei Basic Auth',
4801 'api_password' => 'dealer_api_password_xxxx',
4802 'marketplace_id' => 'DE',
4803 'seller_id' => 'customer_123456',
4804 'account_id' => 'mobileSellerId_123456',
4805 'location_key' => 'location_123456',
4806 'category_id' => 'car / motorbike / commercial',
4807 'payment_policy_id' => 'nicht benoetigt',
4808 'fulfillment_policy_id' => 'nicht benoetigt',
4809 'return_policy_id' => 'nicht benoetigt',
4810 'notification_destination' => 'https://middleware.example.de/mobile-leads',
4811 'notification_topic' => 'lead-api',
4812 'webhook_secret' => 'lead_secret_xxxx',
4813 'webhook_url' => 'wird von dbxShop erzeugt',
4814 'api_scope' => "seller-api\nbasic-auth\nlead-api",
4815 ),
4816 'custom' => array(
4817 'channel_key' => 'mein-channel',
4818 'api_base_url' => 'https://api.anbieter.de/v1',
4819 'api_client_id' => 'client_123456',
4820 'api_username' => 'api-user@example.de',
4821 'api_client_secret' => 'client_secret_xxxxx',
4822 'api_access_token' => 'access_token_xxxxx',
4823 'api_refresh_token' => 'refresh_token_xxxxx',
4824 'api_password' => 'API-Passwort',
4825 'marketplace_id' => 'DE',
4826 'seller_id' => 'seller_123456',
4827 'account_id' => 'account_123456',
4828 'location_key' => 'lager-1',
4829 'category_id' => '12345',
4830 'payment_policy_id' => 'payment_123',
4831 'fulfillment_policy_id' => 'shipping_123',
4832 'return_policy_id' => 'return_123',
4833 'notification_destination' => 'https://domain.de/webhook',
4834 'notification_topic' => 'order.created',
4835 'webhook_secret' => 'zufaelliges-geheimes-secret',
4836 'webhook_url' => 'wird von dbxShop erzeugt',
4837 'api_scope' => "products:write\norders:read\nwebhooks:read",
4838 ),
4839 );
4840 $placeholders = $placeholderMap[$platform] ?? $placeholderMap['custom'];
4841 $ph = function (string $field) use ($placeholders): string {
4842 return $this->h($placeholders[$field] ?? '');
4843 };
4844 $secretPlaceholder = function (string $field) use ($placeholders): string {
4845 return $this->h((string)($placeholders[$field] ?? ''));
4846 };
4847 $status = (string)($channel['test_status'] ?? '');
4848 $statusBadge = $status === 'ok'
4849 ? '<span class="badge text-bg-success">Test ok</span>'
4850 : ($status === 'error' ? '<span class="badge text-bg-warning">Test offen</span>' : '<span class="badge text-bg-secondary">Nicht getestet</span>');
4851 $activeBadge = (int)($channel['active'] ?? 1) === 1
4852 ? '<span class="badge text-bg-success">Aktiv</span>'
4853 : '<span class="badge text-bg-secondary">Inaktiv</span>';
4854 $exportBadge = (int)($channel['export_enabled'] ?? 0) === 1 ? '<span class="badge text-bg-info">Export</span>' : '';
4855 $orderBadge = (int)($channel['order_import_enabled'] ?? 0) === 1 ? '<span class="badge text-bg-primary">Order-Import</span>' : '';
4856 $webhookPath = ($key !== '' && $platform !== 'shop') ? '?dbx_modul=dbxShop&dbx_run1=channel_webhook&channel=' . rawurlencode($key) : '';
4857 $open = $isNew || (int)($_GET['edit'] ?? 0) === $id;
4858 $editUrl = '?dbx_modul=dbxShop_admin&dbx_run1=channels&edit=' . $id;
4859 $helpId = $this->ensureShopChannelProviderHelpPage($platform);
4860 $helpButton = '';
4861 if ($helpId > 0) {
4862 $helpTitle = 'Hilfe: Channel ' . ($platformOptions[$platform] ?? 'Channel');
4863 $helpButton = $this->openWinButton(
4864 '?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId . '&dbx_ajax=1',
4865 $helpTitle,
4866 '<i class="bi bi-question-circle"></i><span class="visually-hidden"> Hilfe</span>',
4867 'btn btn-outline-secondary btn-sm dbx-shop-channel-help',
4868 '72%',
4869 '82%'
4870 );
4871 $helpButton = str_replace('<a ', '<a onclick="event.stopPropagation();" ', $helpButton);
4872 }
4873
4874 $form = dbx()->get_system_obj('dbxForm');
4875 $form->init('shop-channel-form-' . ($isNew ? 'new' : $id), 'shop-channel-form');
4876 $form->_dd = 'dbxShop|shopChannel';
4877 $form->_fd = 'dbxShop|shop-channel';
4878 $form->_data = $channel + array('id' => $id);
4879 $form->_rid = $isNew ? 0 : $id;
4880 $form->_action = '?dbx_modul=dbxShop_admin&dbx_run1=channels' . ($isNew ? '&new=1' : '&edit=' . $id);
4881 $form->set_activ_id($isNew ? 0 : $id);
4882 $form->add_rep('frame_skip_form_wrap', '0');
4883 $form->add_rep('form_class', 'dbx-shop-channel-dbXForm');
4884 $form->add_rep('form_attrs', 'data-target="dbxForm_{i}" data-dbx="lib=confirm|class=dbxConfirm|bind=button"');
4885 $form->add_rep('details_open', $open ? ' open' : '');
4886 $form->add_rep('channel_key_view', $this->h($key !== '' ? $key : 'neu'));
4887 $form->add_rep('channel_title_view', $this->h($channel['title'] ?? 'Neuer Channel'));
4888 $form->add_rep('platform_view', $this->h($platformOptions[$platform] ?? $platform));
4889 $form->add_rep('connection_view', $this->h($modeOptions[(string)($channel['connection_mode'] ?? 'manual')] ?? ($channel['connection_mode'] ?? 'manual')));
4890 $form->add_rep('active_badge', $activeBadge);
4891 $form->add_rep('export_badge', $exportBadge);
4892 $form->add_rep('order_badge', $orderBadge);
4893 $form->add_rep('status_badge', $statusBadge);
4894 $form->add_rep('last_test_date', !empty($channel['last_test_date']) ? $this->h($channel['last_test_date']) : '');
4895 $form->add_rep('channel_help_button', $helpButton);
4896 $form->add_rep('channel_edit_button', !$isNew ? '<a class="btn btn-outline-primary btn-sm dbx-shop-channel-edit" onclick="event.stopPropagation();" href="' . $this->h($editUrl) . '" title="Channel bearbeiten"><i class="bi bi-pencil-square"></i> Bearbeiten</a>' : '');
4897 $form->add_rep('hint_api', $this->h($hint[0]));
4898 $form->add_rep('hint_listing', $this->h($hint[1]));
4899 $form->add_rep('hint_feedback', $this->h($hint[2]));
4900 $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>' : '');
4901 $form->add_rep('test_button', !$isNew ? '<button class="btn btn-outline-secondary btn-sm ms-1" name="shop_action" value="test_channel" title="Verbindung testen"><i class="bi bi-plug"></i> Test</button>' : '');
4902 $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="Channel loeschen" data-confirm-title="<i class=\'bi bi-trash\'></i> Channel loeschen" data-confirm="Diesen Channel wirklich loeschen?" data-confirm-hint="<small>Artikelzuordnungen fuer diesen Channel werden deaktiviert. Dieser Vorgang sollte nur ausgefuehrt werden, wenn der Channel nicht mehr verwendet werden soll.</small>" data-confirm-buttons="yesno"><i class="bi bi-trash"></i></button>' : '');
4903 $form->_msg_info = '';
4904
4905 $form->add_fld('id', tpl: 'dbx|hidden', label: 'ID', rules: 'int', dd: 'dd::');
4906 $form->add_fld('channel_key', label: 'Key', placeholder: $placeholders['channel_key'] ?? '', data: $isNew ? '' : 'readonly=readonly');
4907 $form->add_fld('title', label: 'Channel');
4908 $form->add_fld('platform_type', tpl: 'select-single-label', label: 'Plattform', options: $platformOptions);
4909 $form->add_fld('connection_mode', tpl: 'select-single-label', label: 'Verbindung', options: $modeOptions);
4910 $form->add_fld('sorter', label: 'Sort');
4911 $form->add_fld('active', label: 'Aktiv');
4912 $form->add_fld('export_enabled', label: 'Export');
4913 $form->add_fld('order_import_enabled', label: 'Order-Import');
4914 $form->add_fld('description', label: 'Beschreibung', data: 'rows=2');
4915 $form->add_fld('api_base_url', placeholder: $placeholders['api_base_url'] ?? '');
4916 $form->add_fld('api_client_id', placeholder: $placeholders['api_client_id'] ?? '');
4917 $form->add_fld('api_username', placeholder: $placeholders['api_username'] ?? '');
4918 $form->add_fld('api_client_secret', placeholder: $secretPlaceholder('api_client_secret'));
4919 $form->add_fld('api_access_token', placeholder: $secretPlaceholder('api_access_token'));
4920 $form->add_fld('api_refresh_token', placeholder: $secretPlaceholder('api_refresh_token'));
4921 $form->add_fld('api_password', placeholder: $secretPlaceholder('api_password'));
4922 $form->add_fld('marketplace_id', placeholder: $placeholders['marketplace_id'] ?? '');
4923 $form->add_fld('seller_id', placeholder: $placeholders['seller_id'] ?? '');
4924 $form->add_fld('account_id', placeholder: $placeholders['account_id'] ?? '');
4925 $form->add_fld('location_key', placeholder: $placeholders['location_key'] ?? '');
4926 $form->add_fld('category_id', placeholder: $placeholders['category_id'] ?? '');
4927 $form->add_fld('payment_policy_id', placeholder: $placeholders['payment_policy_id'] ?? '');
4928 $form->add_fld('fulfillment_policy_id', placeholder: $placeholders['fulfillment_policy_id'] ?? '');
4929 $form->add_fld('return_policy_id', placeholder: $placeholders['return_policy_id'] ?? '');
4930 $form->add_fld('notification_destination', placeholder: $placeholders['notification_destination'] ?? '');
4931 $form->add_fld('notification_topic', placeholder: $placeholders['notification_topic'] ?? '');
4932 $form->add_fld('webhook_secret', placeholder: $secretPlaceholder('webhook_secret'));
4933 $form->add_fld('api_scope', placeholder: $placeholders['api_scope'] ?? '', data: 'rows=2');
4934 $form->add_obj('webhook_url', 'obj-value', '<label class="form-label">dbxShop Webhook-URL</label><input class="form-control form-control-sm" value="' . $this->h($webhookPath) . '" placeholder="' . $ph('webhook_url') . '" readonly>');
4935
4936 return $form->run();
4937 };
4938
4939 $channels = $this->repo()->channels();
4940 $content = $notice . '<div class="m-3 dbx-shop-channel-list">';
4941 if ((int)($_GET['new'] ?? 0) === 1) {
4942 $sorter = 10;
4943 foreach ($channels as $channel) {
4944 $sorter = max($sorter, (int)($channel['sorter'] ?? 0) + 10);
4945 }
4946 $content .= $rowHtml(array(
4947 'title' => '',
4948 'platform_type' => 'custom',
4949 'connection_mode' => 'api',
4950 'export_enabled' => 1,
4951 'order_import_enabled' => 1,
4952 'active' => 1,
4953 'sorter' => $sorter,
4954 ), true);
4955 }
4956 foreach ($channels as $channel) {
4957 $content .= $rowHtml($channel);
4958 }
4959 $content .= '</div>';
4960
4961 $helpId = $this->ensureShopChannelsHelpPage();
4962 $helpButton = $helpId > 0
4963 ? $this->openWinButton('?dbx_modul=dbxContent&dbx_run1=content&cid=' . $helpId . '&dbx_ajax=1', 'Hilfe: Channels', '<i class="bi bi-question-circle"></i><span class="visually-hidden"> Hilfe</span>', 'btn btn-outline-secondary btn-sm me-1', '72%', '82%')
4964 : '';
4965 $barActions = '<a class="btn btn-outline-primary btn-sm me-1" href="?dbx_modul=dbxShop_admin&dbx_run1=channels&new=1" title="Neuen Channel anlegen"><i class="bi bi-plus-square"></i><span class="visually-hidden"> Neuer Channel</span></a>' . $helpButton;
4966 return $this->frame($content, 'Channels', $barActions);
4967 }
4968
4969 public function run(): string {
4970 $run = dbx()->get_modul_var('dbx_run1', 'dashboard', 'parameter');
4971
4972 switch ($run) {
4973 case '':
4974 case 'dashboard':
4975 case 'start':
4976 return $this->dashboard();
4977
4978 case 'install':
4979 return $this->install();
4980
4981 case 'products':
4982 return $this->products();
4983
4984 case 'product_edit':
4985 return $this->productEdit();
4986
4987 case 'product_tree_move':
4988 return $this->productTreeMove();
4989
4990 case 'product_channel_mapping':
4991 return $this->productChannelMapping();
4992
4993 case 'products_help':
4994 return $this->productsHelp();
4995
4996 case 'groups':
4997 return $this->groups();
4998
4999 case 'attributes':
5000 return $this->attributes();
5001
5002 case 'product_attributes':
5003 return $this->productAttributes();
5004
5005 case 'shipping_groups':
5006 return $this->shippingGroups();
5007
5008 case 'channel_groups':
5009 return $this->channelGroups();
5010
5011 case 'channels':
5012 return $this->channels();
5013
5014 case 'media':
5015 return $this->media();
5016
5017 case 'assign_media':
5018 return $this->assignMedia();
5019
5020 case 'orders':
5021 return $this->orders();
5022
5023 case 'order_detail':
5024 return $this->orderDetail();
5025
5026 case 'order_invoice':
5027 return $this->orderInvoice();
5028
5029 case 'order_invoice_pdf':
5030 return $this->orderInvoicePdf();
5031
5032 case 'legal':
5033 return $this->shopLegalCmsPage(
5034 'legal',
5035 'Rechtstexte',
5036 'Diese Seite kommt aus dem CMS unter /shop/rechtstexte. Inhalte wie Anbieterkennzeichnung, AGB, Zahlung, Versand und Datenschutz-Hinweise werden dort gepflegt.',
5037 'legal'
5038 );
5039
5040 case 'returns':
5041 return $this->returns();
5042
5043 case 'settings':
5044 return $this->settings();
5045
5046 case 'payment_test':
5047 return $this->paymentTest();
5048
5049 default:
5050 return $this->placeholder('Unbekannter Shop-Aufruf', 'dbx_run1=' . $run . ' ist im Shop-Admin-Skeleton noch nicht definiert.');
5051 }
5052 }
5053}
5054?>
$cfg
withdrawal_report_next_record($report, $record)
$field
Definition config.dd.php:4
exit
Definition index.php:532
dbx_get_file_dir()
Definition index.php:265
if( $syncRequest)
Definition index.php:520
DBX schema administration.
if(! $db->connect_db_server($server)) $result
$_SERVER['REQUEST_URI']
foreach( $topics as $file=> $html)