dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxShopService.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxShop;
3
4require_once dirname(__DIR__, 2) . '/dbxContent/include/dbxContent_bootstrap_sync.php';
5
7
8 private array $textForms = array();
9
13 private function texts(string $fd): \dbxForm {
14 if (isset($this->textForms[$fd])) {
15 return $this->textForms[$fd];
16 }
17
18 dbx()->get_system_obj('dbxForm', 'use');
19 $form = new \dbxForm();
20 $form->set_form_help_enabled(false);
21 $form->_fd = $fd;
22 $form->load_fd_messages();
23 $this->textForms[$fd] = $form;
24 return $form;
25 }
26
27 private function tpl() {
28 return dbx()->get_system_obj('dbxTPL');
29 }
30
31 private function repo(): dbxShopRepository {
32 return dbx()->get_include_obj('dbxShopRepository', 'dbxShop');
33 }
34
35 private function paypal(): dbxShopPayPal {
36 return dbx()->get_include_obj('dbxShopPayPal', 'dbxShop');
37 }
38
39 private function amazonPay(): dbxShopAmazonPay {
40 return dbx()->get_include_obj('dbxShopAmazonPay', 'dbxShop');
41 }
42
43 private function h($value): string {
44 return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
45 }
46
47 private function readJsonArray($value): array {
48 $data = json_decode((string)$value, true);
49 return is_array($data) ? $data : array();
50 }
51
52 private function money($value): string {
53 $language = strtolower(
54 substr((string)dbx()->get_system_var('dbx_lng', 'de'), 0, 2)
55 );
56 return ($language === 'en'
57 ? number_format((float)$value, 2, '.', ',')
58 : number_format((float)$value, 2, ',', '.')) . ' EUR';
59 }
60
61 private function shopConfig(): array {
62 $cfg = dbx()->get_config('dbxShop');
63 return is_array($cfg) ? $cfg : array();
64 }
65
66 private function settingsBool(array $cfg, string $key, bool $default = false): bool {
67 if (!array_key_exists($key, $cfg)) {
68 return $default;
69 }
70 $value = $cfg[$key];
71 if (is_bool($value)) return $value;
72 return in_array(strtolower(trim((string)$value)), array('1', 'true', 'yes', 'on'), true);
73 }
74
75 private function shopStyle(): string {
76 $file = dirname(__DIR__) . '/design/css/shop.css';
77 if (!is_file($file)) {
78 return '';
79 }
80 return '<style>' . file_get_contents($file) . '</style>';
81 }
82
83 private function demoShopNoticeHtml(string $id = '', string $extraClass = '', $texts = null): string {
84 if (!$this->settingsBool($this->shopConfig(), 'demo_notice_enabled', true)) {
85 return '';
86 }
87 $texts = $texts ?: $this->texts('dbxShop|shop-catalog-filter-form');
88 $idAttribute = $id !== '' ? ' id="' . $this->h($id) . '"' : '';
89 $classAttribute = $extraClass !== '' ? ' ' . $this->h($extraClass) : '';
90 return '<div' . $idAttribute . ' class="alert alert-danger dbx-shop-demo-alert' . $classAttribute . '" role="alert">'
91 . '<strong><i class="bi bi-exclamation-octagon-fill"></i> '
92 . $this->h($texts->get_fd_message('demo_title'))
93 . '</strong><br>'
94 . $this->h($texts->get_fd_message('demo_message'))
95 . '</div>';
96 }
97
98 private function page(string $title, string $subtitle, string $body, string $active = 'catalog'): string {
99 return $this->tpl()->get_tpl('dbxShop|start', array(
100 'shop_style' => $this->shopStyle(),
101 'title' => $this->h($title),
102 'subtitle' => $this->h($subtitle),
103 'body' => $body,
104 'active_catalog' => $active === 'catalog' ? 'active' : '',
105 'active_cart' => $active === 'cart' ? 'active' : '',
106 'active_checkout' => $active === 'checkout' ? 'active' : '',
107 'active_orders' => $active === 'orders' ? 'active' : '',
108 'active_legal' => $active === 'legal' ? 'active' : '',
109 'active_withdrawal' => $active === 'withdrawal' ? 'active' : '',
110 ));
111 }
112
113 private function contentDb() {
114 $db = dbx()->get_system_obj('dbxDB');
115 if (!is_object($db) || !$db->connect_db_server('dbx|dbxContent.db3')) {
116 return null;
117 }
118 return $db;
119 }
120
121 private function findContentFolder($db, string $name, int $parentId): int {
122 $name = trim($name);
123 $parentId = (int) $parentId;
124 if ($name === '') {
125 return 0;
126 }
127 $where = "name = '" . str_replace("'", "''", $name) . "' AND parent_id = " . $parentId;
128 $rows = $db->select(\dbx\dbxContent\dbxContentLng::ddFolder(), $where, 'id', 'id', 'ASC', '', 1, 0, 0);
129 if (!is_array($rows) || !isset($rows[0]['id'])) {
130 return 0;
131 }
132 return (int) $rows[0]['id'];
133 }
134
135 private function nextFolderSorter($db, int $parentId): string {
136 $parentId = (int) $parentId;
137 $rows = $db->select(\dbx\dbxContent\dbxContentLng::ddFolder(), 'parent_id = ' . $parentId, 'sorter', 'sorter,id', 'DESC', '', 1, 0, 0);
138 $max = 0;
139 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
140 $max = (int) ($rows[0]['sorter'] ?? 0);
141 }
142 return sprintf('%04d', $max + 10);
143 }
144
145 private function nextContentSorter($db, int $folderId): string {
146 $folderId = (int) $folderId;
147 $rows = $db->select(\dbx\dbxContent\dbxContentLng::ddContent(), 'folder = ' . $folderId, 'sorter', 'sorter,id', 'DESC', '', 1, 0, 0);
148 $max = 0;
149 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
150 $max = (int) ($rows[0]['sorter'] ?? 0);
151 }
152 return sprintf('%04d', $max + 10);
153 }
154
155 private function ensureShopContentFolder($db): int {
156 $folderId = $this->findContentFolder($db, 'shop', 0);
157 if ($folderId > 0) {
158 return $folderId;
159 }
160
161 $data = array(
162 'name' => 'shop',
163 'parent_id' => 0,
164 'sorter' => $this->nextFolderSorter($db, 0),
165 'group_read' => '*',
166 'template' => 'c-body1-footer',
167 'hero_template' => 'image-hero',
168 'hero_image_id' => 'parent',
169 'hero_margin_top' => 'parent',
170 'hero_height' => 'parent',
171 'hero_variant' => 'parent',
172 'hero_sticky' => 'parent',
173 'hero_scroll_layer' => 'parent',
174 );
175 $ok = (int) $db->insert(\dbx\dbxContent\dbxContentLng::ddFolder(), $data, 0, 1, 0, 0);
176 if ($ok !== 1) {
177 return 0;
178 }
179 $folderId = (int) $db->get_insert_id();
180 if ($folderId <= 0) {
181 return 0;
182 }
183
184 if ($folderId > 0) {
186 }
187 return $folderId;
188 }
189
190 private function shopLegalPageData($db, int $folderId, string $title, string $permalink, string $content): array {
191 return array(
192 'activ' => 1,
193 'folder' => $folderId,
194 'title' => substr($title, 0, 254),
195 'permalink' => substr($permalink, 0, 254),
196 'description' => '',
197 'keywords' => '',
198 'group_read' => '*',
199 'sorter' => $this->nextContentSorter($db, $folderId),
200 'template' => 'c-body1-footer',
201 'hero_template' => 'parent',
202 'hero_image_id' => 'parent',
203 'hero_margin_top' => 'parent',
204 'hero_height' => 'parent',
205 'hero_variant' => 'parent',
206 'hero_sticky' => 'parent',
207 'hero_scroll_layer' => 'parent',
208 'gallery_template' => 'image-gallery',
209 'gallery_visible_count' => '3',
210 'gallery_image_size' => 'original',
211 'gallery_lightbox_width' => '100vw',
212 'gallery_overflow' => 'grid',
213 'gallery_click_behavior' => 'lightbox',
214 'content' => $content,
215 );
216 }
217
218 private function syncContentPermalink(int $cid, string $permalink): void {
219 if ($cid <= 0 || trim($permalink) === '') {
220 return;
221 }
223 }
224
225 private function ensureShopLegalPage($db, int $folderId, string $title, string $permalink, string $content, array $legacyPermalinks = array()): int {
227 $existing = null;
228 foreach (array_values(array_unique(array_merge(array($permalink), $legacyPermalinks))) as $candidate) {
229 $existing = $db->select1($dd, array('permalink' => $candidate), 'id,content,permalink', 0);
230 if (is_array($existing) && (int)($existing['id'] ?? 0) > 0) {
231 break;
232 }
233 }
234 if (is_array($existing) && (int) ($existing['id'] ?? 0) > 0) {
235 $id = (int) $existing['id'];
236 $storedContent = trim((string) ($existing['content'] ?? ''));
237 if ($storedContent === '') {
238 $data = $this->shopLegalPageData($db, $folderId, $title, $permalink, $content);
239 unset($data['sorter'], $data['folder']);
240 $db->update($dd, $data, $id, 0, 1, 1, 0);
242 } else {
243 $db->update($dd, array('permalink' => $permalink, 'template' => 'c-body1-footer', 'group_read' => '*', 'activ' => 1), $id, 0, 1, 1, 0);
244 }
245 $this->syncContentPermalink($id, $permalink);
246 return $id;
247 }
248
249 $data = $this->shopLegalPageData($db, $folderId, $title, $permalink, $content);
250 $ok = (int) $db->insert($dd, $data, 0, 1, 0, 0);
251 if ($ok !== 1) {
252 return 0;
253 }
254 $id = (int) $db->get_insert_id();
255 if ($id <= 0) {
256 return 0;
257 }
258 if ($id > 0) {
260 $this->syncContentPermalink($id, $permalink);
261 }
262 return $id;
263 }
264
265 public function ensureShopLegalPages(): array {
266 $db = $this->contentDb();
267 if (!is_object($db)) {
268 return array();
269 }
270 $folderId = $this->ensureShopContentFolder($db);
271 if ($folderId <= 0) {
272 return array();
273 }
274
275 return array(
276 'legal' => $this->ensureShopLegalPage($db, $folderId, 'Rechtstexte', 'shop-rechtstexte', $this->defaultLegalContent(), array('shop/rechtstexte')),
277 'withdrawal' => $this->ensureShopLegalPage($db, $folderId, 'Widerruf', 'shop-widerruf', $this->defaultWithdrawalContent(), array('shop/widerruf')),
278 );
279 }
280
281 private function renderCmsShopPage(string $key, string $title, string $subtitle, string $active): string {
282 $pages = $this->ensureShopLegalPages();
283 $cid = (int) ($pages[$key] ?? 0);
284 if ($cid <= 0) {
285 return $this->page($title, $subtitle, $this->placeholder($title, 'Die CMS-Seite konnte nicht angelegt oder geladen werden.'), $active);
286 }
287
288 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
289 $body = is_object($renderer) ? (string) $renderer->renderStatic($cid, array('template' => 'c-body1-footer')) : '';
290 if (trim($body) === '') {
291 $body = $this->placeholder($title, 'Die CMS-Seite ist leer.');
292 }
293 return $this->page($title, $subtitle, '<div class="dbx-shop-cms-page">' . $body . '</div>', $active);
294 }
295
302 private function withdrawalFormHtml($form): string {
303 $form->_action = '?dbx_modul=dbxShop&dbx_run1=withdrawal';
304 $form->_data = array_merge($form->_data, array(
305 'order_no' => (string)($_POST['order_no'] ?? ''),
306 'customer_name' => (string)($_POST['customer_name'] ?? ''),
307 'customer_email' => (string)($_POST['customer_email'] ?? ''),
308 'customer_address' => (string)($_POST['customer_address'] ?? ''),
309 'reason' => (string)($_POST['reason'] ?? ''),
310 ));
311 $form->add_module_bar(
312 $form->get_fd_message('bar_title'),
313 'bi-arrow-counterclockwise',
314 $form->get_fd_message('bar_subtitle')
315 );
316 $form->_msg_info = $form->get_fd_message('form_info');
317 $form->_msg_error = $form->get_fd_message('validation_error');
318 $form->add_rep('bar_title', $form->get_fd_message('bar_title'));
319 $form->add_flds();
320 if ($form->submit()) {
321 if ($form->errors()) {
322 $form->_msg_error = $form->get_fd_message(
323 'validation_error'
324 );
325 } else {
326 $values = array(
327 'order_no' => $form->get_post('order_no', '', 'parameter|max=40'),
328 'customer_name' => $form->get_post_data('customer_name', '', '*|min=2|max=180'),
329 'customer_email' => $form->get_post('customer_email', '', 'email|max=180'),
330 'customer_address' => $form->get_post_data('customer_address', '', '*|min=8|max=2000'),
331 'reason' => $form->get_post_data('reason', '', '*|max=3000'),
332 );
333
334 $row = $this->repo()->saveWithdrawal($values);
335 if (is_array($row)) {
336 $this->sendWithdrawalMails($row);
337 foreach (array_keys($values) as $fieldName) {
338 $form->set_fld_val($fieldName, '');
339 }
340 $form->_msg_success = $form->get_fd_message(
341 'withdrawal_success'
342 );
343 } else {
344 $form->_msg_error = $form->get_fd_message(
345 'withdrawal_error'
346 );
347 }
348 }
349 }
350
351 return $form->run();
352 }
353
354 private function defaultLegalContent(): string {
355 return <<<'HTML'
356<div class="dbx-shop-legal-text">
357 <h1>Rechtstexte</h1>
358 <p class="dbx-shop-legal-note"><strong>Hinweis:</strong> Dies ist ein Mustertext fuer einen deutschen Online-Shop. Alle Platzhalter in eckigen Klammern muessen vor der Veroeffentlichung durch echte Betreiber-, Register-, Steuer-, Zahlungs- und Versanddaten ersetzt und rechtlich geprueft werden.</p>
359
360 <h2>Anbieterkennzeichnung</h2>
361 <p><strong>[Name/Firma des Shop-Betreibers]</strong><br>
362 [Strasse und Hausnummer]<br>
363 [PLZ Ort]<br>
364 Deutschland</p>
365 <p>Vertreten durch: [vertretungsberechtigte Person]<br>
366 E-Mail: <a href="mailto:[E-Mail-Adresse]">[E-Mail-Adresse]</a><br>
367 Telefon: [Telefonnummer]</p>
368 <p>Registereintrag: [Registergericht und Registernummer, falls vorhanden]<br>
369 Umsatzsteuer-ID: [USt-IdNr., falls vorhanden]<br>
370 Wirtschafts-ID: [W-IdNr., falls vorhanden]</p>
371 <p>Zustaendige Aufsichtsbehoerde: [nur eintragen, wenn fuer die Taetigkeit erforderlich]</p>
372
373 <h2>Geltungsbereich</h2>
374 <p>Diese Rechtstexte gelten fuer Bestellungen ueber diesen Shop. Abweichende Bedingungen von Kunden gelten nur, wenn der Shop-Betreiber ihnen ausdruecklich zustimmt.</p>
375
376 <h2>Vertragspartner und Vertragsschluss</h2>
377 <p>Der Kaufvertrag kommt zustande mit <strong>[Name/Firma des Shop-Betreibers]</strong>. Die Darstellung der Produkte im Shop ist kein rechtlich bindendes Angebot, sondern eine Aufforderung zur Bestellung. Der Kunde gibt ein verbindliches Angebot ab, wenn er den Bestellprozess abschliesst. Die Annahme erfolgt durch Bestellbestaetigung, Zahlungsaufforderung, Versandbestaetigung oder Lieferung der Ware.</p>
378
379 <h2>Preise, Zahlung und Rechnung</h2>
380 <p>Alle Preise verstehen sich in Euro und enthalten die gesetzliche Umsatzsteuer, sofern diese im Shop ausgewiesen wird. Zusaetzliche Versandkosten werden vor Abgabe der Bestellung angezeigt. Verfuegbare Zahlungsarten sind: [Zahlungsarten eintragen, z. B. PayPal, Ueberweisung, Rechnung]. Die Rechnung wird elektronisch oder in Textform bereitgestellt.</p>
381
382 <h2>Lieferung und Versand</h2>
383 <p>Die Lieferung erfolgt an die vom Kunden angegebene Lieferadresse. Liefergebiete, Versandarten, Versandkosten und Lieferzeiten ergeben sich aus den Angaben im Bestellprozess. Bei digitalen Produkten erfolgt die Bereitstellung per Download, E-Mail, Kundenkonto oder Freischaltung.</p>
384
385 <h2>Eigentumsvorbehalt</h2>
386 <p>Gelieferte Waren bleiben bis zur vollstaendigen Bezahlung Eigentum des Shop-Betreibers.</p>
387
388 <h2>Maengelhaftung</h2>
389 <p>Es gilt das gesetzliche Maengelhaftungsrecht. Kunden werden gebeten, offensichtliche Transportschaeden moeglichst schnell beim Zusteller und beim Shop-Betreiber zu melden. Die gesetzlichen Rechte des Kunden bleiben davon unberuehrt.</p>
390
391 <h2>Digitale Inhalte und Dienstleistungen</h2>
392 <p>Bei digitalen Inhalten, Downloads, Software, Online-Zugaengen oder Dienstleistungen koennen besondere Hinweise zur Vertragsausfuehrung, Kompatibilitaet, Laufzeit, Kuendigung und zum Widerrufsrecht erforderlich sein. Diese Angaben muessen beim jeweiligen Produkt und im Bestellprozess klar dargestellt werden.</p>
393
394 <h2>Streitbeilegung</h2>
395 <p>Die Europaeische Kommission stellt eine Plattform zur Online-Streitbeilegung bereit: <a href="https://ec.europa.eu/consumers/odr/" target="_blank" rel="noopener">https://ec.europa.eu/consumers/odr/</a>. Der Shop-Betreiber ist [bereit/nicht bereit] und [verpflichtet/nicht verpflichtet], an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.</p>
396
397 <h2>Datenschutz</h2>
398 <p>Informationen zur Verarbeitung personenbezogener Daten, zu Kontaktformularen, Zahlungsdiensten, Versanddienstleistern, Cookies, Logfiles und Rechten betroffener Personen stehen in der Datenschutzerklaerung: <a href="?dbx_modul=dbxContent&amp;dbx_permalink=datenschutz">Datenschutzerklaerung</a>.</p>
399</div>
400HTML;
401 }
402
403 private function defaultWithdrawalContent(): string {
404 return <<<'HTML'
405<div class="dbx-shop-legal-text">
406 <h1>Widerruf</h1>
407 <p class="dbx-shop-legal-note"><strong>Hinweis:</strong> Dies ist ein Muster fuer Verbraucherbestellungen nach deutschem Recht. Platzhalter muessen ersetzt und die Ausnahmen fuer konkrete Produkte, digitale Inhalte und Dienstleistungen geprueft werden.</p>
408
409 <h2>Widerrufsrecht</h2>
410 <p>Verbraucher haben grundsaetzlich das Recht, einen Vertrag binnen vierzehn Tagen ohne Angabe von Gruenden zu widerrufen. Die Widerrufsfrist betraegt vierzehn Tage ab dem Tag, an dem der Kunde oder ein benannter Dritter die Ware erhalten hat. Bei digitalen Inhalten oder Dienstleistungen kann der Fristbeginn und das Erloeschen des Widerrufsrechts abweichen.</p>
411
412 <h2>Ausuebung des Widerrufs</h2>
413 <p>Um das Widerrufsrecht auszuueben, muss der Kunde den Shop-Betreiber mit einer eindeutigen Erklaerung ueber den Entschluss informieren, den Vertrag zu widerrufen. Die Erklaerung kann per Brief, E-Mail oder ueber ein im Shop bereitgestelltes Formular erfolgen.</p>
414 <p><strong>Widerruf an:</strong><br>
415 [Name/Firma des Shop-Betreibers]<br>
416 [Strasse und Hausnummer]<br>
417 [PLZ Ort]<br>
418 E-Mail: <a href="mailto:[E-Mail-Adresse]">[E-Mail-Adresse]</a></p>
419
420 <h2>Folgen des Widerrufs</h2>
421 <p>Wenn der Kunde den Vertrag widerruft, werden alle Zahlungen einschliesslich der Standard-Lieferkosten unverzueglich und spaetestens binnen vierzehn Tagen ab Eingang der Widerrufserklaerung zurueckgezahlt. Fuer die Rueckzahlung wird dasselbe Zahlungsmittel verwendet, das bei der urspruenglichen Transaktion eingesetzt wurde, sofern nichts anderes vereinbart wurde.</p>
422 <p>Bei Waren kann die Rueckzahlung verweigert werden, bis die Ware wieder eingegangen ist oder der Kunde den Nachweis erbracht hat, dass die Ware zurueckgesendet wurde. Der Kunde hat die Ware unverzueglich und spaetestens binnen vierzehn Tagen ab Widerruf zurueckzusenden.</p>
423
424 <h2>Ruecksendekosten und Wertersatz</h2>
425 <p>Die unmittelbaren Kosten der Ruecksendung traegt [Kunde/Shop-Betreiber - bitte passend eintragen]. Fuer einen Wertverlust der Ware muss der Kunde nur aufkommen, wenn dieser Wertverlust auf einen nicht notwendigen Umgang mit der Ware zurueckzufuehren ist.</p>
426
427 <h2>Ausschluss oder Erloeschen des Widerrufsrechts</h2>
428 <p>Das Widerrufsrecht kann insbesondere ausgeschlossen oder vorzeitig erloschen sein bei individuell angefertigten Waren, versiegelten Waren aus Hygiene- oder Gesundheitsschutzgruenden nach Entfernung der Versiegelung, schnell verderblichen Waren, bestimmten Dienstleistungen sowie digitalen Inhalten, wenn der Kunde ausdruecklich zugestimmt hat, dass die Ausfuehrung vor Ablauf der Widerrufsfrist beginnt, und die gesetzlich erforderlichen Bestaetigungen erteilt wurden.</p>
429
430 <h2>Muster-Widerrufsformular</h2>
431 <p>Wenn Sie den Vertrag widerrufen wollen, koennen Sie diesen Text verwenden und an den Shop-Betreiber senden:</p>
432 <div class="dbx-shop-withdrawal-form">
433 <p>An [Name/Firma, Anschrift und E-Mail-Adresse des Shop-Betreibers]</p>
434 <p>Hiermit widerrufe ich den von mir abgeschlossenen Vertrag ueber den Kauf der folgenden Waren oder die Erbringung der folgenden Dienstleistung:</p>
435 <p>[Artikel/Dienstleistung eintragen]</p>
436 <p>Bestellt am: [Datum]<br>Erhalten am: [Datum]</p>
437 <p>Name des Kunden: [Name]<br>Anschrift des Kunden: [Anschrift]</p>
438 <p>Datum: [Datum]</p>
439 <p>Unterschrift des Kunden: [nur bei Mitteilung auf Papier]</p>
440 </div>
441</div>
442HTML;
443 }
444
445 private function mediaUrl(string $path): string {
446 $path = trim(str_replace('\\', '/', $path));
447 if ($path === '') {
448 return '';
449 }
450 if (preg_match('~^https?://~i', $path) || substr($path, 0, 1) === '/') {
451 return $path;
452 }
453 return dbx()->get_base_url() . ltrim($path, '/');
454 }
455
456 private function mediaItemUrl(array $image, bool $thumb = false): string {
457 $mediaId = (int)($image['media_id'] ?? 0);
458 if ($mediaId > 0) {
459 $url = 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $mediaId;
460 if ($thumb) {
461 $url .= '&dbx_thumb=1';
462 }
463 return $url;
464 }
465 return $this->mediaUrl((string)($image['image_path'] ?? ''));
466 }
467
468 private function productImage(array $product): array {
469 $images = $product['images'] ?? array();
470 if (is_array($images) && isset($images[0]) && is_array($images[0])) {
471 return $images[0];
472 }
473 return array(
474 'image_path' => 'files/shop/img/software-dashboard.svg',
475 'title' => $product['title'] ?? 'Artikel',
476 'alt' => $product['title'] ?? 'Artikel',
477 );
478 }
479
480 private function primaryGroup(array $product): array {
481 return is_array($product['groups'] ?? null) ? (($product['groups'] ?? array())[0] ?? array()) : array();
482 }
483
484 private function templateName(string $value, string $fallback, string $prefix = ''): string {
485 $value = preg_replace('~[^a-z0-9_-]+~i', '', trim($value));
486 if ($value === '') {
487 return $fallback;
488 }
489 if ($prefix !== '' && strpos($value, $prefix) !== 0) {
490 return $fallback;
491 }
492 return $value;
493 }
494
495 private function shopTemplateExists(string $template): bool {
496 $template = preg_replace('~[^a-z0-9_-]+~i', '', $template);
497 if ($template === '') return false;
498 return is_file(dirname(__DIR__) . '/tpl/htm/' . $template . '.htm');
499 }
500
507 private function shopTemplateFields(string $template): array {
508 static $cache = array();
509 $template = preg_replace('~[^a-z0-9_-]+~i', '', $template);
510 if ($template === '') return array();
511 if (isset($cache[$template])) return $cache[$template];
512
513 $file = dirname(__DIR__) . '/tpl/htm/' . $template . '.htm';
514 $source = is_file($file) ? file_get_contents($file) : '';
515 $fields = array();
516 if (is_string($source) && preg_match_all('~\\{([a-z][a-z0-9_]*)\\}~i', $source, $matches)) {
517 foreach ($matches[1] as $field) {
518 $fields[strtolower((string)$field)] = true;
519 }
520 }
521 $cache[$template] = $fields;
522 return $fields;
523 }
524
525 private function mediaTemplateExists(string $template): bool {
526 $template = preg_replace('~[^a-z0-9_-]+~i', '', strtolower($template));
527 if ($template === '') return false;
528 return is_file(dirname(dirname(__DIR__)) . '/dbxContent/tpl/htm/media-' . $template . '.htm');
529 }
530
531 private function groupSetting(array $product, string $key, $fallback) {
532 $group = $this->primaryGroup($product);
533 $value = $group[$key] ?? $fallback;
534 return $value === '' || $value === null ? $fallback : $value;
535 }
536
537 private function productVisual(array $product, string $class = ''): string {
538 $image = $this->productImage($product);
539 $src = $this->mediaItemUrl($image, true);
540 $alt = (string)($image['alt'] ?? $image['title'] ?? $product['title'] ?? '');
541 $count = count($product['images'] ?? array());
542 $html = '<div class="dbx-shop-product-visual ' . $this->h($class) . '">';
543 $html .= '<img class="dbx-shop-product-img" src="' . $this->h($src) . '" alt="' . $this->h($alt) . '" loading="lazy">';
544 $html .= '<span class="dbx-shop-badge">' . $this->h($product['badge'] ?? 'Artikel') . '</span>';
545 if ($count > 1) {
546 $html .= '<span class="dbx-shop-image-count"><i class="bi bi-images"></i> ' . (int)$count . '</span>';
547 }
548 $html .= '</div>';
549 return $html;
550 }
551
552 private function productGallery(array $product): string {
553 $images = $product['images'] ?? array();
554 if (!is_array($images) || $images === array()) {
555 return $this->productVisual($product, 'dbx-shop-product-visual-large');
556 }
557 $overflow = preg_replace('~[^a-z0-9_-]~i', '', (string)$this->groupSetting($product, 'gallery_overflow', 'grid')) ?: 'grid';
558 $click = preg_replace('~[^a-z0-9_-]~i', '', (string)$this->groupSetting($product, 'gallery_click', 'lightbox')) ?: 'lightbox';
559 $visible = max(1, (int)$this->groupSetting($product, 'gallery_visible_count', 3));
560 $imgSize = preg_replace('~[^a-z0-9_-]~i', '', (string)$this->groupSetting($product, 'gallery_image_size', 'original')) ?: 'original';
561 $lightboxWidth = preg_replace('~[^a-z0-9%._-]+~i', '', (string)$this->groupSetting($product, 'gallery_lightbox_width', '100vw')) ?: '100vw';
562 $template = preg_replace('~[^a-z0-9_-]+~i', '', strtolower((string)$this->groupSetting($product, 'gallery_template', 'image-gallery'))) ?: 'image-gallery';
563 if (!$this->mediaTemplateExists($template)) {
564 $template = 'image-gallery';
565 }
566 $html = '<div class="dbx-shop-product-gallery dbx-content-media-gallery gallery-list gallery-template-' . $this->h($template) . '" data-dbx="lib=gallery|overflow=' . $this->h($overflow) . '|click=' . $this->h($click) . '|img-count=' . $visible . '|img-size=' . $this->h($imgSize) . '|lightbox-width=' . $this->h($lightboxWidth) . '">';
567 foreach ($images as $image) {
568 $url = $this->mediaItemUrl($image, false);
569 $thumbUrl = $this->mediaItemUrl($image, true);
570 if ($url === '') {
571 continue;
572 }
573 $title = (string)($image['title'] ?? $product['title'] ?? '');
574 $alt = (string)($image['alt'] ?? $title);
575 $caption = $title;
576 $html .= $this->tpl()->get_tpl('dbxContent|media-' . $template, array(
577 'id' => (string)($image['media_id'] ?? ''),
578 'url' => $this->h($url),
579 'thumb_url' => $this->h($thumbUrl),
580 'poster_url' => $this->h($thumbUrl),
581 'media_type' => 'image',
582 'title' => $this->h($title),
583 'alt' => $this->h($alt),
584 'caption' => $this->h($caption),
585 'slot' => 'gallery',
586 'mime' => '',
587 ));
588 }
589 $html .= '</div>';
590 return $html;
591 }
592
593 private function placeholder(string $headline, string $text, array $items = array()): string {
594 $list = '';
595 foreach ($items as $item) {
596 $list .= '<li>' . $this->h($item) . '</li>';
597 }
598
599 return $this->tpl()->get_tpl('dbxShop|placeholder', array(
600 'headline' => $this->h($headline),
601 'text' => $this->h($text),
602 'items' => $list !== '' ? '<ul>' . $list . '</ul>' : '',
603 ));
604 }
605
606 private function ensureSeed(): void {
607 // Der oeffentliche GET-Pfad darf keine Demo- oder Wartungsdaten
608 // anlegen. Seed und Migration werden im Admin explizit ausgefuehrt.
609 $this->repo()->install();
610 }
611
612 private function activeChannel(): string {
613 return 'shop';
614 }
615
616 private function channelNav(string $active): string {
617 $channels = $this->repo()->channels();
618 $html = '<div class="dbx-shop-channel-nav">';
619 foreach ($channels as $channel) {
620 $key = (string)($channel['channel_key'] ?? '');
621 if ($key === '') {
622 continue;
623 }
624 $cls = $key === $active ? ' active' : '';
625 $html .= '<a class="btn btn-outline-secondary btn-sm' . $cls . '" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog&amp;channel=' . rawurlencode($key) . '">';
626 $html .= $this->h($channel['title'] ?? $key);
627 $html .= '</a>';
628 }
629 $html .= '</div>';
630 return $html;
631 }
632
633 private function productHasChannel(array $product, string $channel): bool {
634 foreach (($product['channels'] ?? array()) as $ch) {
635 if ((string)($ch['channel_key'] ?? '') === $channel && (int)($ch['active'] ?? 0) === 1) {
636 return true;
637 }
638 }
639 return false;
640 }
641
642 private function groupsHtml(array $product): string {
643 $html = '';
644 foreach (($product['groups'] ?? array()) as $group) {
645 $groupId = (int)($group['id'] ?? 0);
646 $href = $groupId > 0 ? '?dbx_modul=dbxShop&amp;dbx_run1=catalog&amp;group=' . $groupId : '';
647 $label = $this->h($group['title'] ?? '');
648 $html .= $href !== ''
649 ? '<a class="dbx-shop-chip" href="' . $href . '">' . $label . '</a>'
650 : '<span class="dbx-shop-chip">' . $label . '</span>';
651 }
652 return $html;
653 }
654
655 private function catalogGroupId(): int {
656 return max(0, (int)dbx()->get_modul_var('group', 0, 'int'));
657 }
658
659 private function groupImageUrl(array $group): string {
660 $image = $this->repo()->primaryImageForGroup((int)($group['id'] ?? 0));
661 if (is_array($image)) {
662 $url = $this->mediaItemUrl($image, true);
663 if ($url !== '') {
664 return $url;
665 }
666 }
667 return $this->mediaUrl('files/shop/img/software-dashboard.svg');
668 }
669
670 private function catalogGroupBreadcrumb(int $groupId): string {
671 if ($groupId <= 0) {
672 return '';
673 }
674 $path = $this->repo()->groupPath($groupId);
675 if ($path === array()) {
676 return '';
677 }
678 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
679 $html = '<nav class="dbx-shop-group-breadcrumb" aria-label="'
680 . $this->h($texts->get_fd_message('groups_aria')) . '">';
681 $html .= '<a href="?dbx_modul=dbxShop&amp;dbx_run1=catalog">'
682 . $this->h($texts->get_fd_message('all_products')) . '</a>';
683 foreach ($path as $group) {
684 $id = (int)($group['id'] ?? 0);
685 $title = $this->h($group['title'] ?? '');
686 if ($id === $groupId) {
687 $html .= '<span>' . $title . '</span>';
688 } else {
689 $html .= '<a href="?dbx_modul=dbxShop&amp;dbx_run1=catalog&amp;group=' . $id . '">' . $title . '</a>';
690 }
691 }
692 $html .= '</nav>';
693 return $html;
694 }
695
696 private function catalogGroupNavigation(int $parentId): string {
697 $groups = $this->repo()->groupsByParent($parentId, true);
698 if ($groups === array()) {
699 return '';
700 }
701 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
702 $html = '<section class="dbx-shop-group-grid" aria-label="'
703 . $this->h($texts->get_fd_message('groups_aria')) . '">';
704 foreach ($groups as $group) {
705 $id = (int)($group['id'] ?? 0);
706 if ($id <= 0) continue;
707 $title = trim((string)(
708 $group['title'] ?? $texts->get_fd_message('group_fallback')
709 ));
710 $description = trim((string)($group['description'] ?? ''));
711 $html .= '<a class="dbx-shop-group-card" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog&amp;group=' . $id . '">';
712 $html .= '<span class="dbx-shop-group-card-image"><img src="' . $this->h($this->groupImageUrl($group)) . '" alt="' . $this->h($title) . '" loading="lazy"></span>';
713 $html .= '<span class="dbx-shop-group-card-body"><strong>' . $this->h($title) . '</strong>';
714 if ($description !== '') {
715 $html .= '<small>' . $this->h($description) . '</small>';
716 }
717 $html .= '</span></a>';
718 }
719 $html .= '</section>';
720 return $html;
721 }
722
723 private function productInCatalogGroup(array $product, int $groupId): bool {
724 if ($groupId <= 0) {
725 return true;
726 }
727 if ((int)($product['product_group_id'] ?? 0) === $groupId) {
728 return true;
729 }
730 foreach (($product['groups'] ?? array()) as $group) {
731 if ((int)($group['id'] ?? 0) === $groupId) {
732 return true;
733 }
734 }
735 return false;
736 }
737
738 private function channelsHtml(array $product): string {
739 $html = '';
740 foreach (($product['channels'] ?? array()) as $channel) {
741 if ((int)($channel['active'] ?? 0) !== 1) {
742 continue;
743 }
744 $html .= '<span class="dbx-shop-chip dbx-shop-chip-channel">' . $this->h($channel['title'] ?? $channel['channel_key'] ?? '') . '</span>';
745 }
746 return $html;
747 }
748
749 private function normalizedText(string $value): string {
750 $value = strtolower($value);
751 $value = strtr($value, array('ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'));
752 $value = preg_replace('~[^a-z0-9]+~', ' ', $value) ?: '';
753 return preg_replace('~\\s+~', ' ', trim($value)) ?: '';
754 }
755
756 private function attributeText(array $product): string {
757 $parts = array();
758 foreach (($product['attributes'] ?? array()) as $attribute) {
759 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
760 $parts[] = (string)($attribute['title'] ?? '');
761 $parts[] = (string)($attribute['attr_key'] ?? '');
762 if ($value !== '') {
763 $parts[] = $value;
764 }
765 }
766 return implode(' ', $parts);
767 }
768
769 private function groupText(array $product): string {
770 $parts = array();
771 foreach (($product['groups'] ?? array()) as $group) {
772 $parts[] = (string)($group['title'] ?? '');
773 $parts[] = (string)($group['group_key'] ?? '');
774 $parts[] = (string)($group['description'] ?? '');
775 $parts[] = (string)($group['attribute_notes'] ?? '');
776 }
777 return implode(' ', $parts);
778 }
779
780 private function searchTerms(string $query): array {
781 $terms = preg_split('~\\s+~', $this->normalizedText($query)) ?: array();
782 $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'));
783 $out = array();
784 foreach ($terms as $term) {
785 $term = trim($term);
786 if ($term === '' || isset($stopWords[$term])) {
787 continue;
788 }
789 if (strlen($term) < 2 && !ctype_digit($term)) {
790 continue;
791 }
792 $out[$term] = true;
793 }
794 return array_keys($out);
795 }
796
797 private function textMatchesSearchTerm(string $text, string $term): bool {
798 return $this->searchFieldScore($text, $term, 1) > 0;
799 }
800
801 private function searchFieldScore(string $text, string $term, int $weight): int {
802 if ($text === '' || $term === '') {
803 return 0;
804 }
805 if ($text === $term) {
806 return $weight * 8;
807 }
808 $termLength = strlen($term);
809 $compactText = str_replace(' ', '', $text);
810 $compactTerm = str_replace(' ', '', $term);
811 if (strpos($text, $term) !== false || strpos($compactText, $compactTerm) !== false) {
812 return $weight * 5;
813 }
814 $best = 0;
815 foreach (preg_split('~\\s+~', $text) ?: array() as $token) {
816 $token = trim($token);
817 if ($token === '') {
818 continue;
819 }
820 if ($token === $term) {
821 $best = max($best, $weight * 6);
822 continue;
823 }
824 if ($termLength < 3) {
825 continue;
826 }
827 if (strlen($token) >= $termLength && strpos($token, $term) === 0) {
828 $best = max($best, $weight * 4);
829 continue;
830 }
831 if (
832 $termLength >= 4
833 && strlen($token) >= 4
834 && substr($token, 0, 3) === substr($term, 0, 3)
835 && abs(strlen($token) - $termLength) <= ($termLength >= 7 ? 2 : 1)
836 && levenshtein($token, $term) <= ($termLength >= 7 ? 2 : 1)
837 ) {
838 $best = max($best, $weight * 2);
839 }
840 }
841 return $best;
842 }
843
844 private function productSearchScore(array $product, string $query): int {
845 $terms = $this->searchTerms($query);
846 if ($terms === array()) {
847 return 1;
848 }
849
850 $primary = $this->normalizedText(implode(' ', array(
851 (string)($product['sku'] ?? ''),
852 (string)($product['title'] ?? ''),
853 (string)($product['category'] ?? ''),
854 (string)($product['badge'] ?? ''),
855 (string)($product['product_type'] ?? ''),
856 )));
857 $secondary = $this->normalizedText(implode(' ', array(
858 (string)($product['summary'] ?? ''),
859 (string)($product['description'] ?? ''),
860 )));
861 $attributes = $this->normalizedText($this->attributeText($product));
862 $groups = $this->normalizedText($this->groupText($product));
863
864 $score = 0;
865 $matched = 0;
866 $firstTermPrimaryScore = 0;
867 $termCount = count($terms);
868
869 foreach ($terms as $idx => $term) {
870 $primaryScore = $this->searchFieldScore($primary, $term, 10);
871 $termScore = max(
872 $primaryScore,
873 $this->searchFieldScore($attributes, $term, 7),
874 $this->searchFieldScore($secondary, $term, 4),
875 $this->searchFieldScore($groups, $term, 3)
876 );
877
878 if ($idx === 0) {
879 $firstTermPrimaryScore = $primaryScore;
880 }
881 if ($termScore > 0) {
882 $matched++;
883 $score += $termScore;
884 }
885 }
886
887 if ($matched === 0) {
888 return 0;
889 }
890 if ($termCount === 1) {
891 return $score;
892 }
893
894 if ($matched === $termCount || $firstTermPrimaryScore > 0 || $score >= 20) {
895 return $score + ($matched * 3);
896 }
897
898 return 0;
899 }
900
901 private function attributesInlineHtml(array $product, int $max = 4): string {
902 $html = '';
903 $count = 0;
904 foreach (($product['attributes'] ?? array()) as $attribute) {
905 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
906 if ($value === '') continue;
907 $html .= '<span class="dbx-shop-attribute-chip"><span>' . $this->h($attribute['title'] ?? '') . '</span><strong>' . $this->h($value) . '</strong></span>';
908 $count++;
909 if ($count >= $max) break;
910 }
911 return $html !== '' ? '<div class="dbx-shop-attribute-row">' . $html . '</div>' : '';
912 }
913
914 private function attributesTableHtml(array $product): string {
915 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
916 $rows = '';
917 foreach (($product['attributes'] ?? array()) as $attribute) {
918 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
919 if ($value === '') continue;
920 $rows .= '<tr><th>' . $this->h($attribute['title'] ?? '') . '</th><td>' . $this->h($value) . '</td></tr>';
921 }
922 if ($rows === '') {
923 return '';
924 }
925 return '<div class="dbx-shop-attributes"><h4>'
926 . $this->h($texts->get_fd_message('attributes_heading'))
927 . '</h4><table><tbody>' . $rows . '</tbody></table></div>';
928 }
929
930 private function selectedAttributeFilters(): array {
931 $raw = $_GET['attr'] ?? array();
932 if (!is_array($raw)) {
933 return array();
934 }
935 $out = array();
936 foreach ($raw as $id => $value) {
937 $id = (int)$id;
938 $value = trim((string)$value);
939 if ($id > 0 && $value !== '') {
940 $out[$id] = $value;
941 }
942 }
943 return $out;
944 }
945
946 private function productMatchesQuery(array $product, string $query): bool {
947 return $this->productSearchScore($product, $query) > 0;
948 }
949
950 private function productMatchesAttributeFilters(array $product, array $filters): bool {
951 if ($filters === array()) {
952 return true;
953 }
954 $values = array();
955 foreach (($product['attributes'] ?? array()) as $attribute) {
956 $id = (int)($attribute['id'] ?? 0);
957 if ($id <= 0) continue;
958 $values[$id] = $this->normalizedText((string)($attribute['value_text'] ?? ''));
959 }
960 foreach ($filters as $id => $value) {
961 if (!isset($values[$id]) || $values[$id] !== $this->normalizedText((string)$value)) {
962 return false;
963 }
964 }
965 return true;
966 }
967
968 private function catalogFiltersHtml(string $channel, string $query, array $selected, int $groupId = 0): string {
969 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
970 $filterFields = '';
971 foreach ($this->repo()->attributeFilterDefinitions() as $definition) {
972 $id = (int)($definition['id'] ?? 0);
973 $values = $definition['values'] ?? array();
974 if ($id <= 0 || !is_array($values) || $values === array()) continue;
975 $label = trim((string)($definition['title'] ?? ''));
976 $group = trim((string)($definition['group_title'] ?? ''));
977 $filterFields .= '<label><span>' . $this->h($group !== '' ? $group . ': ' . $label : $label) . '</span><select class="form-select form-select-sm" name="attr[' . $id . ']">';
978 $filterFields .= '<option value="">'
979 . $this->h($texts->get_fd_message('all_option'))
980 . '</option>';
981 foreach ($values as $value) {
982 $sel = isset($selected[$id]) && $this->normalizedText((string)$selected[$id]) === $this->normalizedText((string)$value) ? ' selected' : '';
983 $filterFields .= '<option value="' . $this->h($value) . '"' . $sel . '>' . $this->h($value) . '</option>';
984 }
985 $filterFields .= '</select></label>';
986 }
987 $advancedFilters = '';
988 if ($filterFields !== '') {
989 $open = $selected !== array() ? ' open' : '';
990 $advancedFilters .= '<details class="dbx-shop-filter-advanced"' . $open . '>';
991 $advancedFilters .= '<summary><i class="bi bi-sliders"></i> '
992 . $this->h($texts->get_fd_message('refine_filters'))
993 . '</summary>';
994 $advancedFilters .= '<div class="dbx-shop-filter-row">' . $filterFields . '</div>';
995 $advancedFilters .= '</details>';
996 }
997
998 $form = dbx()->get_system_obj('dbxForm');
999 $form->init('shop-catalog-filter-form', 'shop-catalog-filter-form');
1000 $form->set_editor_class_file(__FILE__);
1001 $form->_fd = 'dbxShop|shop-catalog-filter-form';
1002 $form->load_fd_messages();
1003 $form->_action = '?dbx_modul=dbxShop&dbx_run1=catalog';
1004 $form->_data = array('q' => $query);
1005 $form->_msg_info = '';
1006 $form->_msg_success = '';
1007 $form->_msg_error = '';
1008 $form->_msg_warning = '';
1009 $form->add_rep('bar_title', $texts->get_fd_message('bar_title'));
1010 $form->add_rep('frame_skip_form_wrap', '1');
1011 $form->add_fld('q');
1012 $form->add_rep('advanced_filters', $advancedFilters);
1013 $form->add_rep('group_hidden', $groupId > 0 ? '<input type="hidden" name="group" value="' . $groupId . '">' : '');
1014 return $form->run();
1015 }
1016
1023 private function productTemplateData(
1024 array $product,
1025 string $channel,
1026 bool $detail = false,
1027 ?array $templateFields = null
1028 ): array {
1029 $sku = (string)($product['sku'] ?? '');
1030 $uses = static fn(string $field): bool => $templateFields === null || isset($templateFields[$field]);
1031 $data = array();
1032 if ($uses('sku')) $data['sku'] = $this->h($sku);
1033 if ($uses('title')) $data['title'] = $this->h($product['title'] ?? '');
1034 if ($uses('summary')) $data['summary'] = $this->h($product['summary'] ?? '');
1035 if ($uses('description')) {
1036 $data['description'] = $this->h($product['description'] ?? $product['summary'] ?? '');
1037 }
1038 if ($uses('groups')) $data['groups'] = $this->groupsHtml($product);
1039 if ($uses('channels')) $data['channels'] = '';
1040 if ($uses('attributes')) {
1041 $data['attributes'] = $detail
1042 ? $this->attributesTableHtml($product)
1043 : $this->attributesInlineHtml($product, 4);
1044 }
1045 if ($uses('attributes_table')) {
1046 $data['attributes_table'] = $this->attributesTableHtml($product);
1047 }
1048 if ($uses('gallery')) $data['gallery'] = $this->productGallery($product);
1049 if ($uses('visual')) $data['visual'] = $this->productVisual($product);
1050 if ($uses('price')) $data['price'] = $this->money($product['price_gross'] ?? 0);
1051 if ($uses('tax_shipping')) $data['tax_shipping'] = $this->taxShippingHtml($product);
1052 if ($uses('shipping_info')) $data['shipping_info'] = $this->shippingInfoHtml($product);
1053 if ($uses('stock_info')) $data['stock_info'] = $this->stockInfoHtml($product);
1054 if ($uses('buy_form')) $data['buy_form'] = $this->buyFormHtml($product);
1055 if ($uses('detail_url')) {
1056 $data['detail_url'] = '?dbx_modul=dbxShop&amp;dbx_run1=product&amp;sku=' . rawurlencode($sku);
1057 }
1058 if ($uses('catalog_url')) $data['catalog_url'] = '?dbx_modul=dbxShop&amp;dbx_run1=catalog';
1059 if ($uses('cart_url')) {
1060 $data['cart_url'] = '?dbx_modul=dbxShop&amp;dbx_run1=cart&amp;sku=' . rawurlencode($sku);
1061 }
1062 if ($uses('card_class')) {
1063 $data['card_class'] = $this->h($this->cssTemplateClass(
1064 (string)$this->groupSetting($product, 'card_template', 'product-card-default')
1065 ));
1066 }
1067 if ($uses('detail_class')) {
1068 $data['detail_class'] = $this->h($this->cssTemplateClass(
1069 (string)$this->groupSetting($product, 'detail_template', 'product-detail-default')
1070 ));
1071 }
1072 return $data;
1073 }
1074
1075 private function cssTemplateClass(string $template): string {
1076 $template = preg_replace('~[^a-z0-9_-]+~i', '-', trim($template));
1077 return $template !== '' ? 'is-template-' . strtolower($template) : '';
1078 }
1079
1080 private function renderProductCard(array $product, string $channel): string {
1081 $template = $this->templateName((string)$this->groupSetting($product, 'card_template', 'product-card-default'), 'product-card-default', 'product-card-');
1082 if (!$this->shopTemplateExists($template)) {
1083 $template = 'product-card-default';
1084 }
1085 return $this->tpl()->get_tpl(
1086 'dbxShop|' . $template,
1087 $this->productTemplateData($product, $channel, false, $this->shopTemplateFields($template))
1088 );
1089 }
1090
1091 private function catalogReportHtml(array $products, string $channel, string $query, array $attributeFilters, int $groupId): string {
1092 $report = dbx()->get_system_obj('dbxReport');
1093 $report->init('shop-catalog-report', 'dbxShop|shop-catalog-report');
1094 $report->_fd = 'dbxShop|shop-catalog-filter-form';
1095 $report->load_fd_messages();
1096 $report->set_editor_class_file(__FILE__);
1097 $report->_mode = 'tpl';
1098 $report->_pages = true;
1099 $report->_create_row_select = false;
1100 $report->_create_row_edit = false;
1101 $report->_create_row_delete = false;
1102 $report->_but_pagination = 7;
1103 $rowsPerPage = max(6, min(48, (int)$report->get_fld_val('dbx_rrows', 12, 'int')));
1104 $position = max(0, (int)$report->get_fld_val('dbx_rpos', 0, 'int'));
1105 $filteredCount = count($products);
1106 if ($position >= $filteredCount && $filteredCount > 0) {
1107 $position = max(0, (int)(floor(($filteredCount - 1) / $rowsPerPage) * $rowsPerPage));
1108 }
1109 $visibleCandidates = array_slice($products, $position, $rowsPerPage);
1110 $visible = $this->repo()->productsByIds(array_map(
1111 static fn($product) => (int)($product['id'] ?? 0),
1112 $visibleCandidates
1113 ));
1114 $rows = array();
1115 foreach ($visible as $product) {
1116 $rows[] = array(
1117 'id' => (int)($product['id'] ?? 0),
1118 'card' => $this->renderProductCard($product, $channel),
1119 );
1120 }
1121
1122 $queryParts = array(
1123 'dbx_modul' => 'dbxShop',
1124 'dbx_run1' => 'catalog',
1125 );
1126 if ($query !== '') {
1127 $queryParts['q'] = $query;
1128 }
1129 if ($groupId > 0) {
1130 $queryParts['group'] = $groupId;
1131 }
1132 foreach ($attributeFilters as $id => $value) {
1133 $queryParts['attr[' . (int)$id . ']'] = (string)$value;
1134 }
1135 $report->_action = '?' . http_build_query($queryParts, '', '&');
1136 $report->_rflds = array(
1137 'card' => $report->get_fd_message('column_products'),
1138 );
1139 $report->_rpt_format = array('card' => 'html');
1140 $report->_rrows = $rowsPerPage;
1141 $report->_rpos = $position;
1142 $report->_count_all = $filteredCount;
1143 $report->_rcount = $filteredCount;
1144 $report->_rdata = $rows;
1145 return $report->run();
1146 }
1147
1148 private function renderProductDetail(array $product, string $channel): string {
1149 $template = $this->templateName((string)$this->groupSetting($product, 'detail_template', 'product-detail-default'), 'product-detail-default', 'product-detail-');
1150 if (!$this->shopTemplateExists($template)) {
1151 $template = 'product-detail-default';
1152 }
1153 $data = $this->productTemplateData(
1154 $product,
1155 $channel,
1156 true,
1157 $this->shopTemplateFields($template)
1158 );
1159 return $this->tpl()->get_tpl('dbxShop|' . $template, $data);
1160 }
1161
1162 private function taxShippingHtml(array $product): string {
1163 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
1164 $tax = $this->h(number_format((float)($product['effective_tax_rate'] ?? 0), 2, ',', '.'));
1165 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
1166 $shippingText = $shipping > 0
1167 ? $this->money($shipping) . ' ' . $texts->get_fd_message('shipping_suffix')
1168 : $texts->get_fd_message('free_shipping');
1169 $showTax = $this->settingsBool($this->shopConfig(), 'tax_display_enabled', true);
1170 $parts = array();
1171 if ($showTax) {
1172 $parts[] = $tax . '% ' . $texts->get_fd_message('tax_label');
1173 }
1174 $parts[] = $this->h($shippingText);
1175 return '<small>' . implode(', ', $parts) . '</small>';
1176 }
1177
1178 private function shippingInfoHtml(array $product): string {
1179 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
1180 $deliveryTime = trim((string)($product['effective_delivery_time'] ?? ''));
1181 $shippingWay = trim((string)($product['effective_shipping_way'] ?? ''));
1182 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
1183 $shippingText = $shipping > 0
1184 ? $this->money($shipping)
1185 : $texts->get_fd_message('free_shipping');
1186 $rows = '';
1187
1188 if ($deliveryTime !== '') {
1189 $rows .= '<div class="dbx-shop-shipping-info-row"><i class="bi bi-clock"></i><span>'
1190 . $this->h($texts->get_fd_message('delivery_time'))
1191 . ': ' . $this->h($deliveryTime) . '</span></div>';
1192 }
1193 if ($shippingWay !== '') {
1194 $rows .= '<div class="dbx-shop-shipping-info-row"><i class="bi bi-truck"></i><span>'
1195 . $this->h($texts->get_fd_message('shipping_method'))
1196 . ': ' . $this->h($shippingWay) . '</span></div>';
1197 }
1198 $rows .= '<div class="dbx-shop-shipping-info-row"><i class="bi bi-box-seam"></i><span>'
1199 . $this->h($texts->get_fd_message('shipping_costs'))
1200 . ': ' . $this->h($shippingText) . '</span></div>';
1201
1202 return '<div class="dbx-shop-shipping-info">' . $rows . '</div>';
1203 }
1204
1205 private function stockInfoHtml(array $product): string {
1206 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
1207 $cfg = $this->shopConfig();
1208 if (!$this->settingsBool($cfg, 'stock_enabled', false) || (string)($product['product_type'] ?? '') !== 'physical') {
1209 return '';
1210 }
1211 $stock = (int)($product['stock'] ?? 0);
1212 if ($stock <= 0) {
1213 return '<div class="alert alert-warning py-2 mb-2"><i class="bi bi-exclamation-triangle"></i> '
1214 . $this->h($texts->get_fd_message('stock_out')) . '</div>';
1215 }
1216 if ($stock <= 3) {
1217 return '<div class="alert alert-info py-2 mb-2"><i class="bi bi-box-seam"></i> '
1218 . $this->h($texts->format_fd_message(
1219 'stock_low',
1220 array('count' => $stock)
1221 )) . '</div>';
1222 }
1223 return '<div class="dbx-shop-shipping-info-row"><i class="bi bi-box-seam"></i><span>'
1224 . $this->h($texts->get_fd_message('stock_available'))
1225 . '</span></div>';
1226 }
1227
1234 private function buyForm(array $product): ?\dbxForm {
1235 $sku = (string)($product['sku'] ?? '');
1236 if ($sku === '') {
1237 return null;
1238 }
1239 $cfg = $this->shopConfig();
1240 if ($this->settingsBool($cfg, 'stock_enabled', false) && (string)($product['product_type'] ?? '') === 'physical' && (int)($product['stock'] ?? 0) <= 0) {
1241 return null;
1242 }
1243 $form = dbx()->get_system_obj('dbxForm');
1244 $form->init('shop-buy-' . preg_replace('~[^a-z0-9_-]+~i', '-', strtolower($sku)), 'shop-buy-form');
1245 $form->_fd = 'dbxShop|shop-cart';
1246 $form->load_fd_messages();
1247 $form->set_editor_class_file(__FILE__);
1248 $form->_action = '?dbx_modul=dbxShop&dbx_run1=cart&sku=' . rawurlencode($sku);
1249 $form->_data = array_merge($form->_data, array('qty' => 1));
1250 $form->_msg_info = '';
1251 $form->_msg_success = '';
1252 $form->_msg_error = '';
1253 $form->_msg_warning = '';
1254 $form->add_rep('bar_title', $form->get_fd_message('buy_form_title'));
1255 $form->add_rep('frame_skip_form_wrap', '1');
1256 $form->add_rep('buy_form_id', preg_replace('~[^a-z0-9_-]+~i', '-', $sku));
1257 $form->add_rep('catalog_url', '?dbx_modul=dbxShop&amp;dbx_run1=catalog');
1258 $form->add_fld(
1259 'qty',
1260 'dbxShop|shop-field-qty',
1261 label: $form->get_fd_message('label_quantity'),
1262 rules: 'int|min=1'
1263 );
1264 return $form;
1265 }
1266
1267 private function buyFormHtml(array $product): string {
1268 $form = $this->buyForm($product);
1269 if (!$form) {
1270 $texts = $this->texts('dbxShop|shop-cart');
1271 return '<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog"><i class="bi bi-arrow-left"></i> '
1272 . $this->h($texts->get_fd_message('back_to_catalog'))
1273 . '</a>';
1274 }
1275 return $form->run();
1276 }
1277
1278 private function startSession(): void {
1279 if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
1280 session_start();
1281 }
1282 if (!isset($_SESSION['dbxShop_cart']) || !is_array($_SESSION['dbxShop_cart'])) {
1283 $_SESSION['dbxShop_cart'] = array();
1284 }
1285 }
1286
1287 private function checkoutRequestId(): string {
1288 $posted = strtolower(trim((string)($_POST['checkout_request_id'] ?? '')));
1289 if (preg_match('/^[a-f0-9]{32,64}$/', $posted) === 1) {
1290 return $posted;
1291 }
1292 return bin2hex(random_bytes(24));
1293 }
1294
1295 private function checkoutRequestOrder(string $requestId): ?array {
1296 $this->startSession();
1297 $requests = $_SESSION['dbxShop_checkout_requests'] ?? array();
1298 $orderNo = is_array($requests) ? (string)($requests[$requestId] ?? '') : '';
1299 return $orderNo !== '' ? $this->repo()->orderByNo($orderNo) : null;
1300 }
1301
1302 private function rememberCheckoutRequest(string $requestId, array $order): void {
1303 $this->startSession();
1304 $requests = $_SESSION['dbxShop_checkout_requests'] ?? array();
1305 $requests = is_array($requests) ? $requests : array();
1306 $requests[$requestId] = (string)($order['order_no'] ?? '');
1307 $_SESSION['dbxShop_checkout_requests'] = array_slice($requests, -25, null, true);
1308 }
1309
1310 private function cartItems(): array {
1311 $this->startSession();
1312 return $_SESSION['dbxShop_cart'];
1313 }
1314
1315 private function cartQuantityTotal(): int {
1316 $count = 0;
1317 foreach ($this->cartItems() as $qty) {
1318 $count += max(0, (int)$qty);
1319 }
1320 return $count;
1321 }
1322
1323 private function requestedQuantity($value, int $fallback = 1): int {
1324 $qty = (int)$value;
1325 return max(1, min(999, $qty > 0 ? $qty : $fallback));
1326 }
1327
1328 private function addToCart(string $sku, int $qty = 1): void {
1329 if ($sku === '') {
1330 return;
1331 }
1332 $product = $this->repo()->productBySku($sku);
1333 if (!$product) {
1334 return;
1335 }
1336 $this->startSession();
1337 $_SESSION['dbxShop_cart'][$sku] = max(0, (int)($_SESSION['dbxShop_cart'][$sku] ?? 0)) + $this->requestedQuantity($qty);
1338 }
1339
1340 private function updateCartQuantities(array $quantities): void {
1341 $this->startSession();
1342 foreach ($quantities as $sku => $qty) {
1343 $sku = (string)$sku;
1344 if (!isset($_SESSION['dbxShop_cart'][$sku])) {
1345 continue;
1346 }
1347 $_SESSION['dbxShop_cart'][$sku] = $this->requestedQuantity($qty);
1348 }
1349 }
1350
1351 private function removeFromCart(string $sku): void {
1352 $sku = trim($sku);
1353 if ($sku === '') {
1354 return;
1355 }
1356 $this->startSession();
1357 unset($_SESSION['dbxShop_cart'][$sku]);
1358 }
1359
1360 private function addedToCartDialog(array $product): string {
1361 $texts = $this->texts('dbxShop|shop-cart');
1362 $title = trim((string)($product['title'] ?? $texts->get_fd_message('product')));
1363 $qty = $this->requestedQuantity(dbx()->get_modul_var('qty', '1', 'parameter'));
1364 $body = '<div class="dbx-shop-added-dialog" role="dialog" aria-modal="true" aria-labelledby="dbx-shop-added-title">';
1365 $body .= '<div class="dbx-shop-added-dialog-backdrop"></div>';
1366 $body .= '<div class="dbx-shop-added-dialog-box">';
1367 $body .= '<div class="dbx-shop-added-dialog-icon"><i class="bi bi-check2"></i></div>';
1368 $body .= '<h3 id="dbx-shop-added-title">'
1369 . $this->h($texts->get_fd_message('added_title'))
1370 . '</h3>';
1371 $body .= '<p>' . $this->h($title) . ' <span class="dbx-shop-added-qty">x ' . (int)$qty . '</span></p>';
1372 $body .= '<div class="dbx-shop-added-dialog-actions">';
1373 $body .= '<a class="btn btn-outline-primary" href="?dbx_modul=dbxShop&amp;dbx_run1=cart"><i class="bi bi-cart"></i> '
1374 . $this->h($texts->get_fd_message('cart_title')) . '</a>';
1375 $body .= '<a class="btn btn-primary" href="?dbx_modul=dbxShop&amp;dbx_run1=checkout"><i class="bi bi-credit-card"></i> '
1376 . $this->h($texts->get_fd_message('checkout')) . '</a>';
1377 $body .= '<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog"><i class="bi bi-grid"></i> '
1378 . $this->h($texts->get_fd_message('continue_shopping')) . '</a>';
1379 $body .= '</div>';
1380 $body .= '</div>';
1381 $body .= '</div>';
1382 return $body;
1383 }
1384
1385 private function cartRowsAndSum(bool $editable = false): array {
1386 $rows = '';
1387 $sum = 0.0;
1388 foreach ($this->cartItems() as $sku => $qty) {
1389 $product = $this->repo()->productBySku((string)$sku);
1390 if (!$product) {
1391 continue;
1392 }
1393 $qty = max(1, (int)$qty);
1394 $price = (float)($product['price_gross'] ?? 0);
1395 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
1396 $line = ($price + $shipping) * $qty;
1397 $sum += $line;
1398 $qtyHtml = $editable
1399 ? '<input class="form-control form-control-sm dbx-shop-cart-qty" type="number" min="1" max="999" step="1" name="qty[' . $this->h($sku) . ']" value="' . (int)$qty . '">'
1400 : (string)(int)$qty;
1401 $rows .= '<tr>';
1402 $rows .= '<td><strong>' . $this->h($product['title'] ?? '') . '</strong><br><small>' . $this->h($sku) . '</small></td>';
1403 $rows .= '<td class="text-end">' . $qtyHtml . '</td>';
1404 $rows .= '<td class="text-end">' . $this->money($price) . '</td>';
1405 $rows .= '<td class="text-end">' . $this->money($shipping) . '</td>';
1406 $rows .= '<td class="text-end">' . $this->money($line) . '</td>';
1407 $rows .= '</tr>';
1408 }
1409 return array($rows, $sum);
1410 }
1411
1412 private function cartReportDataAndSum($texts = null): array {
1413 $texts = $texts ?: $this->texts('dbxShop|shop-cart');
1414 $rows = array();
1415 $sum = 0.0;
1416 foreach ($this->cartItems() as $sku => $qty) {
1417 $product = $this->repo()->productBySku((string)$sku);
1418 if (!$product) {
1419 continue;
1420 }
1421 $qty = max(1, (int)$qty);
1422 $price = (float)($product['price_gross'] ?? 0);
1423 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
1424 $line = ($price + $shipping) * $qty;
1425 $sum += $line;
1426 $skuText = (string)$sku;
1427 $rows[] = array(
1428 'id' => $skuText,
1429 'remove' => '<button class="btn btn-sm btn-outline-danger dbxConfirm dbx-shop-cart-remove" type="submit" name="remove" value="' . $this->h($skuText)
1430 . '" data-confirm-title="<i class=\'bi bi-trash\'></i> ' . $this->h($texts->get_fd_message('remove_title'))
1431 . '" data-confirm="' . $this->h($texts->get_fd_message('remove_question'))
1432 . '" data-confirm-hint="<small>' . $this->h($texts->get_fd_message('remove_hint'))
1433 . '</small>" data-confirm-buttons="yesno" title="' . $this->h($texts->get_fd_message('remove_title'))
1434 . '"><i class="bi bi-trash"></i></button>',
1435 'article' => '<strong>' . $this->h($product['title'] ?? '') . '</strong><br><small>' . $this->h($skuText) . '</small>',
1436 'qty' => '<input class="form-control form-control-sm dbx-shop-cart-qty" type="number" min="1" max="999" step="1" name="qty[' . $this->h($skuText) . ']" value="' . (int)$qty . '">',
1437 'price' => '<span class="dbx-shop-money">' . $this->money($price) . '</span>',
1438 'shipping' => '<span class="dbx-shop-money">' . $this->money($shipping) . '</span>',
1439 'line' => '<span class="dbx-shop-money"><strong>' . $this->money($line) . '</strong></span>',
1440 );
1441 }
1442 return array($rows, $sum);
1443 }
1444
1451 private function cartReport(array $rows, float $sum): \dbxReport {
1452 $report = dbx()->get_system_obj('dbxReport');
1453 $report->init('shop-cart-report', 'dbxShop|shop-cart-report');
1454 $report->_fd = 'dbxShop|shop-cart';
1455 $report->load_fd_messages();
1456 $report->set_editor_class_file(__FILE__);
1457 $report->_action = '?dbx_modul=dbxShop&dbx_run1=cart';
1458 $report->_mode = 'table';
1459 $report->_pages = false;
1460 $report->_rdata = $rows;
1461 $report->_rcount = count($rows);
1462 $report->_count_all = count($rows);
1463 $report->_rrows = max(1, count($rows));
1464 $report->_rpos = 0;
1465 $report->_create_row_select = false;
1466 $report->_create_row_edit = false;
1467 $report->_create_row_delete = false;
1468 $report->_rflds = array(
1469 'remove' => $report->get_fd_message('column_action'),
1470 'article' => $report->get_fd_message('column_article'),
1471 'qty' => $report->get_fd_message('column_quantity'),
1472 'price' => $report->get_fd_message('column_price'),
1473 'shipping' => $report->get_fd_message('column_shipping'),
1474 'line' => $report->get_fd_message('column_total'),
1475 );
1476 $report->_rpt_format = array(
1477 'remove' => 'html',
1478 'article' => 'html',
1479 'qty' => 'html',
1480 'price' => 'html',
1481 'shipping' => 'html',
1482 'line' => 'html',
1483 );
1484 $report->add_rep('bar_title', $report->get_fd_message('cart_title'));
1485 $report->add_rep('cart_sum', $this->money($sum));
1486 $report->add_rep('cart_count', (string)$this->cartQuantityTotal());
1487 return $report;
1488 }
1489
1490 private function cartReportHtml(array $rows, float $sum, ?\dbxReport $report = null): string {
1491 if (!$report) {
1492 $report = $this->cartReport($rows, $sum);
1493 } else {
1494 // Nach einer gültigen Aktion nur Ergebnisdaten erneuern. Ein zweites
1495 // init() würde den geprüften Submit-Zustand und den rotierten Token
1496 // verwerfen.
1497 $report->_rdata = $rows;
1498 $report->_rcount = count($rows);
1499 $report->_count_all = count($rows);
1500 $report->_rrows = max(1, count($rows));
1501 $report->add_rep('cart_sum', $this->money($sum));
1502 $report->add_rep('cart_count', (string)$this->cartQuantityTotal());
1503 }
1504 return $report->run();
1505 }
1506
1507 private function cartBodyHtml(?\dbxReport $report = null, $texts = null): string {
1508 $texts = $texts ?: $this->texts('dbxShop|shop-cart');
1509 [$reportRows, $sum] = $this->cartReportDataAndSum($texts);
1510
1511 if ($reportRows === array()) {
1512 return '<div class="dbx-shop-cart-empty" data-dbx-shop-cart-count="0">'
1513 . $this->placeholder(
1514 $texts->get_fd_message('empty_title'),
1515 $texts->get_fd_message('empty_message')
1516 )
1517 . '</div>';
1518 }
1519
1520 return $this->cartReportHtml($reportRows, $sum, $report);
1521 }
1522
1523 private function absoluteShopUrl(string $run, array $params = array()): string {
1524 $query = array_merge(array(
1525 'dbx_modul' => 'dbxShop',
1526 'dbx_run1' => $run,
1527 ), $params);
1528 return dbx()->get_base_url() . '?' . http_build_query($query, '', '&');
1529 }
1530
1531 private function checkoutPaymentOptions(): array {
1532 $cfg = $this->shopConfig();
1533 $texts = $this->texts('dbxShop|checkout');
1534 $options = array();
1535 if ($this->settingsBool($cfg, 'payment_bank_transfer_enabled', true)) {
1536 $options['bank_transfer'] = $texts->get_fd_message('payment_bank_transfer');
1537 }
1538 if ($this->settingsBool($cfg, 'payment_invoice_enabled', false)) {
1539 $options['invoice'] = $texts->get_fd_message('payment_invoice');
1540 }
1541 if ($this->settingsBool($cfg, 'payment_paypal_enabled', false) && $this->paypal()->isConfigured()) {
1542 $options['paypal'] = 'PayPal';
1543 }
1544 if ($this->settingsBool($cfg, 'payment_amazon_pay_enabled', false) && $this->amazonPay()->isConfigured()) {
1545 $options['amazon_pay'] = 'Amazon Pay';
1546 }
1547 return $options;
1548 }
1549
1550 private function paymentMethodLabels(): array {
1551 $texts = $this->texts('dbxShop|checkout');
1552 return array(
1553 'bank_transfer' => $texts->get_fd_message('payment_bank_transfer'),
1554 'invoice' => $texts->get_fd_message('payment_invoice'),
1555 'paypal' => 'PayPal',
1556 'amazon_pay' => 'Amazon Pay',
1557 );
1558 }
1559
1560 private function paymentProviderLabel(string $provider): string {
1561 $labels = $this->paymentMethodLabels();
1562 $texts = $this->texts('dbxShop|shop-orders');
1563 $channelLabels = array(
1564 'shop' => $texts->get_fd_message('provider_shop'),
1565 'amazon' => $texts->get_fd_message('provider_amazon'),
1566 'ebay' => $texts->get_fd_message('provider_ebay'),
1567 'kleinanzeigen' => $texts->get_fd_message('provider_kleinanzeigen'),
1568 'mobile' => $texts->get_fd_message('provider_mobile'),
1569 );
1570 return $labels[$provider] ?? $channelLabels[$provider] ?? $provider;
1571 }
1572
1573 private function paymentInstructions(string $method, array $order = array()): string {
1574 $cfg = $this->shopConfig();
1575 $texts = $this->texts('dbxShop|checkout');
1576 if ($method === 'bank_transfer') {
1577 $lines = array();
1578 $intro = trim((string)($cfg['payment_bank_transfer_instructions'] ?? ''));
1579 if ($intro === '') {
1580 $intro = $texts->get_fd_message('bank_transfer_default');
1581 }
1582 $lines[] = $intro;
1583 foreach (array(
1584 $texts->get_fd_message('account_owner') => 'payment_bank_transfer_account_owner',
1585 'IBAN' => 'payment_bank_transfer_iban',
1586 'BIC' => 'payment_bank_transfer_bic',
1587 'Bank' => 'payment_bank_transfer_bank_name',
1588 ) as $label => $key) {
1589 $value = trim((string)($cfg[$key] ?? ''));
1590 if ($value !== '') {
1591 $lines[] = $label . ': ' . $value;
1592 }
1593 }
1594 if (trim((string)($order['order_no'] ?? '')) !== '') {
1595 $lines[] = $texts->get_fd_message('purpose') . ': ' . (string)$order['order_no'];
1596 }
1597 return implode("\n", $lines);
1598 }
1599 if ($method === 'invoice') {
1600 $text = trim((string)($cfg['payment_invoice_instructions'] ?? ''));
1601 return $text !== '' ? $text : $texts->get_fd_message('invoice_default');
1602 }
1603 if ($method === 'amazon_pay') {
1604 return $texts->get_fd_message('amazon_default');
1605 }
1606 if ($method === 'paypal') {
1607 return $texts->get_fd_message('paypal_default');
1608 }
1609 return '';
1610 }
1611
1612 private function checkoutPaymentHelp(array $options): string {
1613 $texts = $this->texts('dbxShop|checkout');
1614 if ($options === array()) {
1615 return '<div class="alert alert-warning mb-0">' . $this->h($texts->get_fd_message('payment_none_help')) . '</div>';
1616 }
1617 $parts = array();
1618 if (isset($options['bank_transfer'])) {
1619 $parts[] = '<div><strong>' . $this->h($options['bank_transfer']) . '</strong><span>'
1620 . $this->h($texts->get_fd_message('payment_bank_transfer_help')) . '</span></div>';
1621 }
1622 if (isset($options['invoice'])) {
1623 $parts[] = '<div><strong>' . $this->h($options['invoice']) . '</strong><span>'
1624 . $this->h($texts->get_fd_message('payment_invoice_help')) . '</span></div>';
1625 }
1626 if (isset($options['paypal'])) {
1627 $parts[] = '<div><strong>PayPal</strong><span>'
1628 . $this->h($texts->get_fd_message('payment_paypal_help')) . '</span></div>';
1629 }
1630 if (isset($options['amazon_pay'])) {
1631 $parts[] = '<div><strong>Amazon Pay</strong><span>'
1632 . $this->h($texts->get_fd_message('payment_amazon_help')) . '</span></div>';
1633 }
1634 return '<div class="dbx-shop-payment-method-help">' . implode('', $parts) . '</div>';
1635 }
1636
1637 private function checkoutTableHtml(string $rows, float $sum): string {
1638 $texts = $this->texts('dbxShop|checkout');
1639 return '<div class="dbx-shop-cart table-responsive">'
1640 . '<table class="table table-sm align-middle">'
1641 . '<thead><tr><th>' . $this->h($texts->get_fd_message('column_product')) . '</th>'
1642 . '<th class="text-end">' . $this->h($texts->get_fd_message('column_quantity')) . '</th>'
1643 . '<th class="text-end">' . $this->h($texts->get_fd_message('column_price')) . '</th>'
1644 . '<th class="text-end">' . $this->h($texts->get_fd_message('column_shipping')) . '</th>'
1645 . '<th class="text-end">' . $this->h($texts->get_fd_message('column_total')) . '</th></tr></thead>'
1646 . '<tbody>' . $rows . '</tbody>'
1647 . '<tfoot><tr><th colspan="4" class="text-end">' . $this->h($texts->get_fd_message('amount_due'))
1648 . '</th><th class="text-end">' . $this->money($sum) . '</th></tr></tfoot>'
1649 . '</table></div>';
1650 }
1651
1652 private function legalSnapshotsForOrder(): array {
1653 $cfg = $this->shopConfig();
1654 if (!$this->settingsBool($cfg, 'legal_snapshot_enabled', true)) {
1655 return array('', '');
1656 }
1657 $db = $this->contentDb();
1658 if (!is_object($db)) {
1659 return array('', '');
1660 }
1661 $pages = $this->ensureShopLegalPages();
1663 $snapshot = function(string $key) use ($db, $pages, $dd): string {
1664 $cid = (int)($pages[$key] ?? 0);
1665 if ($cid <= 0) {
1666 return '';
1667 }
1668 $row = $db->select1($dd, $cid, 'title,permalink,content,update_date', 0);
1669 if (!is_array($row)) {
1670 return '';
1671 }
1672 return json_encode(array(
1673 'captured_at' => date('Y-m-d H:i:s'),
1674 'title' => (string)($row['title'] ?? ''),
1675 'permalink' => (string)($row['permalink'] ?? ''),
1676 'update_date' => (string)($row['update_date'] ?? ''),
1677 'content' => (string)($row['content'] ?? ''),
1678 ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ?: '';
1679 };
1680 return array($snapshot('legal'), $snapshot('withdrawal'));
1681 }
1682
1683 private function orderSuccessPage(array $order, string $paymentMethod): string {
1684 $this->startSession();
1685 $_SESSION['dbxShop_last_order_no'] = (string)($order['order_no'] ?? '');
1686 $texts = $this->texts('dbxShop|checkout');
1687 $methodLabels = $this->paymentMethodLabels();
1688 $instructions = trim($this->paymentInstructions($paymentMethod, $order));
1689 $body = '<section class="dbx-shop-order-success">'
1690 . '<div class="dbx-shop-order-success-icon"><i class="bi bi-check2-circle"></i></div>'
1691 . '<h2>' . $this->h($texts->get_fd_message('order_saved_title')) . '</h2>'
1692 . '<p>' . $this->h($texts->get_fd_message('order_number_text')) . ' <strong>' . $this->h($order['order_no'] ?? '') . '</strong>.</p>'
1693 . '<dl>'
1694 . '<dt>' . $this->h($texts->get_fd_message('payment_method_label')) . '</dt><dd>' . $this->h($methodLabels[$paymentMethod] ?? $paymentMethod) . '</dd>'
1695 . '<dt>' . $this->h($texts->get_fd_message('status_label')) . '</dt><dd>' . $this->h($texts->get_fd_message('order_waiting')) . '</dd>'
1696 . '<dt>' . $this->h($texts->get_fd_message('total_label')) . '</dt><dd>' . $this->money($order['total_gross'] ?? 0) . '</dd>'
1697 . '</dl>'
1698 . ($instructions !== '' ? '<div class="alert alert-info text-start"><strong>' . $this->h($texts->get_fd_message('payment_note')) . '</strong><br>' . nl2br($this->h($instructions)) . '</div>' : '')
1699 . '<div class="dbx-shop-order-success-actions">'
1700 . '<a class="btn btn-primary" href="?dbx_modul=dbxShop&amp;dbx_run1=orders"><i class="bi bi-receipt"></i> ' . $this->h($texts->get_fd_message('view_orders')) . '</a>'
1701 . '<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog"><i class="bi bi-grid"></i> ' . $this->h($texts->get_fd_message('continue_shopping')) . '</a>'
1702 . '</div>'
1703 . '</section>';
1704 return $this->page(
1705 $texts->get_fd_message('thanks_title'),
1706 $texts->get_fd_message('saved_snapshot_subtitle'),
1707 $body,
1708 'orders'
1709 );
1710 }
1711
1712 private function shopMailFrom(array $cfg): array {
1713 $from = trim((string)($cfg['mail_from'] ?? ''));
1714 $fromName = trim((string)($cfg['mail_from_name'] ?? 'dbxShop'));
1715 return array('email' => $from, 'name' => $fromName);
1716 }
1717
1718 private function shopMailOptions(array $cfg, array $extra = array()): array {
1719 $profile = trim((string)($cfg['mail_profile'] ?? ''));
1720 if ($profile !== '') {
1721 $extra['mail_profile'] = $profile;
1722 }
1723 return $extra;
1724 }
1725
1726 private function orderMailHtml(array $order, string $title): string {
1727 $items = '';
1728 foreach ((array)($order['items'] ?? array()) as $item) {
1729 $items .= '<li>' . (int)($item['qty'] ?? 0) . 'x ' . $this->h($item['title'] ?? '') . ' - ' . $this->money($item['total_gross'] ?? 0) . '</li>';
1730 }
1731 $provider = (string)($order['payment_provider'] ?? '');
1732 $instructions = trim($this->paymentInstructions($provider, $order));
1733 return '<h1>' . $this->h($title) . '</h1>'
1734 . '<p>Bestellnummer: <strong>' . $this->h($order['order_no'] ?? '') . '</strong></p>'
1735 . '<ul>' . $items . '</ul>'
1736 . '<p>Summe: <strong>' . $this->money($order['total_gross'] ?? 0) . '</strong></p>'
1737 . '<p>Status: ' . $this->h($order['status'] ?? '') . ', Zahlung: ' . $this->h($this->paymentProviderLabel($provider)) . ' / ' . $this->h($order['payment_status'] ?? '') . '</p>'
1738 . ($instructions !== '' ? '<p><strong>Zahlungshinweis</strong><br>' . nl2br($this->h($instructions)) . '</p>' : '');
1739 }
1740
1741 private function sendOrderMails(array $order): bool {
1742 $orderId = (int)($order['id'] ?? 0);
1743 if ($orderId <= 0 || $this->repo()->hasOrderHistoryEvent($orderId, 'notification', 'order_mail')) {
1744 return false;
1745 }
1746
1747 $cfg = $this->shopConfig();
1748 $from = $this->shopMailFrom($cfg);
1749 if (filter_var((string)($from['email'] ?? ''), FILTER_VALIDATE_EMAIL) === false) {
1750 dbx()->sys_msg(
1751 'error',
1752 'dbxShop',
1753 (string)$orderId,
1754 'order mail configuration invalid',
1755 'Der konfigurierte Shop-Absender ist keine gültige E-Mail-Adresse.'
1756 );
1757 return false;
1758 }
1759 $mailOptions = $this->shopMailOptions($cfg);
1760 $subject = 'Bestellung ' . (string)($order['order_no'] ?? '');
1761 $sent = 0;
1762 try {
1763 if ($this->settingsBool($cfg, 'mail_customer_enabled', false)) {
1764 $to = trim((string)($order['customer_email'] ?? ''));
1765 if ($to !== '') {
1766 if (dbx()->send_mail($from, $to, $subject, $this->orderMailHtml($order, 'Ihre Bestellung'), 'html', array(), $mailOptions)) {
1767 $sent++;
1768 }
1769 }
1770 }
1771 if ($this->settingsBool($cfg, 'mail_admin_enabled', false)) {
1772 $to = trim((string)($cfg['mail_admin_to'] ?? ''));
1773 if ($to !== '') {
1774 if (dbx()->send_mail($from, $to, '[Shop] ' . $subject, $this->orderMailHtml($order, 'Neue Shop-Bestellung'), 'html', array(), $mailOptions)) {
1775 $sent++;
1776 }
1777 }
1778 }
1779 } catch (\Throwable $e) {
1780 dbx()->sys_msg('error', 'dbxShop', (string)($order['id'] ?? ''), 'order mail failed', $e->getMessage());
1781 }
1782
1783 if ($sent > 0) {
1784 $this->repo()->addOrderHistory($orderId, 'notification', '', 'order_mail', $sent . ' Bestellmail(s) wurden versendet.');
1785 return true;
1786 }
1787 return false;
1788 }
1789
1790 private function sendWithdrawalMails(array $withdrawal): void {
1791 $cfg = $this->shopConfig();
1792 $from = $this->shopMailFrom($cfg);
1793 if (filter_var((string)($from['email'] ?? ''), FILTER_VALIDATE_EMAIL) === false) {
1794 dbx()->sys_msg(
1795 'error',
1796 'dbxShop',
1797 (string)($withdrawal['id'] ?? ''),
1798 'withdrawal mail configuration invalid',
1799 'Der konfigurierte Shop-Absender ist keine gültige E-Mail-Adresse.'
1800 );
1801 return;
1802 }
1803 $mailOptions = $this->shopMailOptions($cfg);
1804 $subject = 'Widerruf ' . (string)($withdrawal['order_no'] ?? '');
1805 $html = '<h1>Widerruf</h1>'
1806 . '<p>Bestellnummer: <strong>' . $this->h($withdrawal['order_no'] ?? '') . '</strong></p>'
1807 . '<p>Name: ' . $this->h($withdrawal['customer_name'] ?? '') . '<br>E-Mail: ' . $this->h($withdrawal['customer_email'] ?? '') . '</p>'
1808 . '<p>' . nl2br($this->h($withdrawal['reason'] ?? '')) . '</p>';
1809 try {
1810 if ($this->settingsBool($cfg, 'mail_customer_enabled', false)) {
1811 $to = trim((string)($withdrawal['customer_email'] ?? ''));
1812 if ($to !== '') {
1813 dbx()->send_mail($from, $to, $subject, $html, 'html', array(), $mailOptions);
1814 }
1815 }
1816 if ($this->settingsBool($cfg, 'mail_admin_enabled', false)) {
1817 $to = trim((string)($cfg['mail_admin_to'] ?? ''));
1818 if ($to !== '') {
1819 dbx()->send_mail($from, $to, '[Shop] ' . $subject, $html, 'html', array(), $mailOptions);
1820 }
1821 }
1822 } catch (\Throwable $e) {
1823 dbx()->sys_msg('error', 'dbxShop', (string)($withdrawal['id'] ?? ''), 'withdrawal mail failed', $e->getMessage());
1824 }
1825 }
1826
1827 private function publicOrderCard(array $order): string {
1828 $texts = $this->texts('dbxShop|shop-orders');
1829 $items = '';
1830 foreach ((array)($order['items'] ?? array()) as $item) {
1831 $items .= '<li><span>' . (int)($item['qty'] ?? 0) . 'x ' . $this->h($item['title'] ?? '') . '</span><strong>' . $this->money($item['total_gross'] ?? 0) . '</strong></li>';
1832 }
1833 if ($items === '') {
1834 $items = '<li><span>' . $this->h($texts->get_fd_message('no_items')) . '</span><strong></strong></li>';
1835 }
1836 $statusLabels = array(
1837 'new' => $texts->get_fd_message('status_new'),
1838 'payment_pending' => $texts->get_fd_message('status_payment_pending'),
1839 'paid' => $texts->get_fd_message('status_paid'),
1840 'processing' => $texts->get_fd_message('status_processing'),
1841 'shipped' => $texts->get_fd_message('status_shipped'),
1842 'done' => $texts->get_fd_message('status_done'),
1843 'cancelled' => $texts->get_fd_message('status_cancelled'),
1844 );
1845 $shippingLabels = array(
1846 'open' => $texts->get_fd_message('shipping_open'),
1847 'ready' => $texts->get_fd_message('shipping_ready'),
1848 'shipped' => $texts->get_fd_message('shipping_shipped'),
1849 'delivered' => $texts->get_fd_message('shipping_delivered'),
1850 'returned' => $texts->get_fd_message('shipping_returned'),
1851 );
1852 $withdrawalLabels = array(
1853 'new' => $texts->get_fd_message('withdrawal_new'),
1854 'processing' => $texts->get_fd_message('withdrawal_processing'),
1855 'accepted' => $texts->get_fd_message('withdrawal_accepted'),
1856 'rejected' => $texts->get_fd_message('withdrawal_rejected'),
1857 'refunded' => $texts->get_fd_message('withdrawal_refunded'),
1858 'closed' => $texts->get_fd_message('withdrawal_closed'),
1859 );
1860 $historyLabels = array(
1861 'created' => $texts->get_fd_message('history_created'),
1862 'status' => $texts->get_fd_message('history_status'),
1863 'payment_status' => $texts->get_fd_message('history_payment_status'),
1864 'shipping_status' => $texts->get_fd_message('history_shipping_status'),
1865 'invoice_no' => $texts->get_fd_message('history_invoice_no'),
1866 'tracking_no' => $texts->get_fd_message('history_tracking_no'),
1867 'payment' => $texts->get_fd_message('history_payment'),
1868 'customer_mail' => $texts->get_fd_message('history_customer_mail'),
1869 'withdrawal' => $texts->get_fd_message('history_withdrawal'),
1870 'withdrawal_status' => $texts->get_fd_message('history_withdrawal_status'),
1871 'stock_release' => $texts->get_fd_message('history_stock_release'),
1872 'invoice_pdf' => $texts->get_fd_message('history_invoice_pdf'),
1873 );
1874 $paymentStatus = (string)($order['payment_status'] ?? '');
1875 $paymentStatusLabel = $texts->get_fd_message('payment_status_' . $paymentStatus, $paymentStatus);
1876 $invoice = trim((string)($order['invoice_no'] ?? ''));
1877 $trackingNo = trim((string)($order['tracking_no'] ?? ''));
1878 $trackingUrl = trim((string)($order['tracking_url'] ?? ''));
1879 $channel = (string)($order['channel_key'] ?? 'shop');
1880 $extra = '';
1881 $extra .= '<span>' . $this->h($texts->get_fd_message('origin')) . ': ' . $this->h($this->paymentProviderLabel($channel)) . '</span>';
1882 if ($invoice !== '') {
1883 $extra .= '<span>' . $this->h($texts->get_fd_message('invoice')) . ': ' . $this->h($invoice) . '</span>';
1884 }
1885 $extra .= '<span>' . $this->h($texts->get_fd_message('shipping')) . ': '
1886 . $this->h($shippingLabels[(string)($order['shipping_status'] ?? 'open')] ?? (string)($order['shipping_status'] ?? 'open')) . '</span>';
1887 if ($trackingNo !== '') {
1888 $trackingText = $this->h($texts->get_fd_message('tracking')) . ': ' . $this->h($trackingNo);
1889 $extra .= $trackingUrl !== ''
1890 ? '<span><a href="' . $this->h($trackingUrl) . '" target="_blank" rel="noopener">' . $trackingText . '</a></span>'
1891 : '<span>' . $trackingText . '</span>';
1892 }
1893 $withdrawalsHtml = '';
1894 foreach ((array)($order['withdrawals'] ?? array()) as $withdrawal) {
1895 $status = (string)($withdrawal['status'] ?? 'new');
1896 $created = trim((string)($withdrawal['create_date'] ?? ''));
1897 $withdrawalsHtml .= '<li><span><strong>' . $this->h($withdrawalLabels[$status] ?? $status) . '</strong>' . ($created !== '' ? '<small>' . $this->h($created) . '</small>' : '') . '</span></li>';
1898 }
1899 if ($withdrawalsHtml !== '') {
1900 $withdrawalsHtml = '<section class="dbx-shop-public-order-withdrawals"><h4><i class="bi bi-arrow-counterclockwise"></i> '
1901 . $this->h($texts->get_fd_message('withdrawals')) . '</h4><ul>' . $withdrawalsHtml . '</ul></section>';
1902 }
1903 $historyHtml = '';
1904 $historyCount = 0;
1905 foreach ((array)($order['history'] ?? array()) as $history) {
1906 if ($historyCount >= 6) {
1907 break;
1908 }
1909 $type = (string)($history['event_type'] ?? '');
1910 $created = trim((string)($history['create_date'] ?? ''));
1911 $message = trim((string)($history['message'] ?? ''));
1912 $old = trim((string)($history['old_value'] ?? ''));
1913 $new = trim((string)($history['new_value'] ?? ''));
1914 $detail = $message !== '' ? $message : trim($old . ($old !== '' && $new !== '' ? ' -> ' : '') . $new);
1915 $historyHtml .= '<li><span><strong>' . $this->h($historyLabels[$type] ?? $type) . '</strong>' . ($detail !== '' ? '<small>' . $this->h($detail) . '</small>' : '') . '</span>' . ($created !== '' ? '<time>' . $this->h($created) . '</time>' : '') . '</li>';
1916 $historyCount++;
1917 }
1918 if ($historyHtml !== '') {
1919 $historyHtml = '<section class="dbx-shop-public-order-history"><h4><i class="bi bi-clock-history"></i> '
1920 . $this->h($texts->get_fd_message('history')) . '</h4><ol>' . $historyHtml . '</ol></section>';
1921 }
1922 $instructions = trim($this->paymentInstructions((string)($order['payment_provider'] ?? ''), $order));
1923 $invoiceLink = '';
1924 $canInvoice = $invoice !== '' || in_array((string)($order['status'] ?? ''), array('paid', 'processing', 'shipped', 'done'), true);
1925 if ($canInvoice) {
1926 $invoiceLink = '<a class="btn btn-outline-primary btn-sm" href="?dbx_modul=dbxShop&amp;dbx_run1=invoice_pdf&amp;order_no=' . rawurlencode((string)($order['order_no'] ?? '')) . '" target="_blank" rel="noopener"><i class="bi bi-file-earmark-pdf"></i> '
1927 . $this->h($texts->get_fd_message('invoice')) . '</a>';
1928 }
1929 return '<article class="dbx-shop-public-order">'
1930 . '<header><div><strong>' . $this->h($order['order_no'] ?? '') . '</strong><small>' . $this->h($order['create_date'] ?? '') . '</small></div><span class="badge text-bg-primary">' . $this->h($statusLabels[(string)($order['status'] ?? '')] ?? ($order['status'] ?? '')) . '</span></header>'
1931 . '<ul>' . $items . '</ul>'
1932 . '<footer><span>' . $this->h($texts->get_fd_message('payment')) . ': '
1933 . $this->h($this->paymentProviderLabel((string)($order['payment_provider'] ?? ''))) . ' / '
1934 . $this->h($paymentStatusLabel) . '</span><strong>' . $this->money($order['total_gross'] ?? 0) . '</strong></footer>'
1935 . ($extra !== '' ? '<div class="dbx-shop-public-order-extra">' . $extra . '</div>' : '')
1936 . ($instructions !== '' && in_array((string)($order['payment_status'] ?? ''), array('open', 'created', 'pending'), true)
1937 ? '<div class="alert alert-info py-2 my-2"><strong>' . $this->h($texts->get_fd_message('payment_note')) . '</strong><br>' . nl2br($this->h($instructions)) . '</div>'
1938 : '')
1939 . $withdrawalsHtml
1940 . $historyHtml
1941 . '<div class="dbx-shop-public-order-actions">' . $invoiceLink
1942 . '<a class="btn btn-outline-secondary btn-sm" href="?dbx_modul=dbxShop&amp;dbx_run1=withdrawal"><i class="bi bi-arrow-counterclockwise"></i> '
1943 . $this->h($texts->get_fd_message('withdrawal')) . '</a></div>'
1944 . '</article>';
1945 }
1946
1947 private function startPayPalForOrder(array $order): string {
1948 $returnUrl = $this->absoluteShopUrl('paypal_return', array('order_no' => (string)$order['order_no']));
1949 $cancelUrl = $this->absoluteShopUrl('paypal_cancel', array('order_no' => (string)$order['order_no']));
1950 $paypalOrder = $this->readJsonArray($order['payment_payload'] ?? '');
1951 $paypalId = (string)($order['payment_reference'] ?? '');
1952 $approvalUrl = $paypalId !== '' ? $this->paypal()->approvalUrl($paypalOrder) : '';
1953 if ($paypalId === '' || $approvalUrl === '') {
1954 $paypalOrder = $this->paypal()->createOrder($order, $returnUrl, $cancelUrl);
1955 }
1956 $paypalId = (string)($paypalOrder['id'] ?? '');
1957 if ($paypalId === ''
1958 || !$this->repo()->updateOrderPayment((int)$order['id'], 'paypal', 'created', $paypalId, $paypalOrder)) {
1959 throw new \RuntimeException('PayPal-Zahlungsreferenz konnte nicht sicher gespeichert werden.');
1960 }
1961 $approvalUrl = $this->paypal()->approvalUrl($paypalOrder);
1962 if ($approvalUrl === '') {
1963 throw new \RuntimeException('PayPal hat keinen Freigabe-Link geliefert.');
1964 }
1965 if (!headers_sent()) {
1966 header('Location: ' . $approvalUrl, true, 302);
1967 exit;
1968 }
1969 return '<a class="btn btn-primary" href="' . $this->h($approvalUrl) . '">Weiter zu PayPal</a>';
1970 }
1971
1972 private function startAmazonPayForOrder(array $order): string {
1973 $returnUrl = $this->absoluteShopUrl('amazon_pay_return', array('order_no' => (string)$order['order_no']));
1974 $cancelUrl = $this->absoluteShopUrl('amazon_pay_cancel', array('order_no' => (string)$order['order_no']));
1975 $checkoutSession = $this->readJsonArray($order['payment_payload'] ?? '');
1976 $checkoutSessionId = (string)($order['payment_reference'] ?? '');
1977 $redirectUrl = $checkoutSessionId !== '' ? $this->amazonPay()->redirectUrl($checkoutSession) : '';
1978 if ($checkoutSessionId === '' || $redirectUrl === '') {
1979 $checkoutSession = $this->amazonPay()->createCheckoutSession($order, $returnUrl, $cancelUrl);
1980 }
1981 $checkoutSessionId = (string)($checkoutSession['checkoutSessionId'] ?? $checkoutSession['id'] ?? '');
1982 if ($checkoutSessionId === ''
1983 || !$this->repo()->updateOrderPayment((int)$order['id'], 'amazon_pay', 'created', $checkoutSessionId, $checkoutSession)) {
1984 throw new \RuntimeException('Amazon-Pay-Referenz konnte nicht sicher gespeichert werden.');
1985 }
1986 $redirectUrl = $this->amazonPay()->redirectUrl($checkoutSession);
1987 if ($redirectUrl === '') {
1988 throw new \RuntimeException('Amazon Pay hat keinen Redirect-Link geliefert.');
1989 }
1990 if (!headers_sent()) {
1991 header('Location: ' . $redirectUrl, true, 302);
1992 exit;
1993 }
1994 return '<a class="btn btn-primary" href="' . $this->h($redirectUrl) . '">Weiter zu Amazon Pay</a>';
1995 }
1996
1997 private function continueCheckoutOrder(array $order, string $paymentMethod): string {
1998 $storedMethod = trim((string)($order['payment_provider'] ?? ''));
1999 if ($storedMethod !== '') $paymentMethod = $storedMethod;
2000 if ($paymentMethod === 'paypal') {
2001 return $this->startPayPalForOrder($order);
2002 }
2003 if ($paymentMethod === 'amazon_pay') {
2004 return $this->startAmazonPayForOrder($order);
2005 }
2006
2007 $this->sendOrderMails($order);
2008 $this->startSession();
2009 $_SESSION['dbxShop_cart'] = array();
2010 return $this->orderSuccessPage($order, $paymentMethod);
2011 }
2012
2013 public function catalog(): string {
2014 $this->ensureSeed();
2015 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
2016 $channel = $this->activeChannel();
2017 $query = trim((string)($_GET['q'] ?? ''));
2018 $groupId = $this->catalogGroupId();
2019 $attributeFilters = $this->selectedAttributeFilters();
2020 $matches = array();
2021 $hasQuery = $this->searchTerms($query) !== array();
2022 $currentGroup = $groupId > 0 ? $this->repo()->groupById($groupId) : null;
2023 if ($groupId > 0 && !is_array($currentGroup)) {
2024 $groupId = 0;
2025 }
2026
2027 foreach ($this->repo()->catalogCandidates($channel) as $product) {
2028 if (!$this->productHasChannel($product, $channel)) {
2029 continue;
2030 }
2031 if (!$this->productInCatalogGroup($product, $groupId)) {
2032 continue;
2033 }
2034 $score = $this->productSearchScore($product, $query);
2035 if ($score <= 0 || !$this->productMatchesAttributeFilters($product, $attributeFilters)) {
2036 continue;
2037 }
2038
2039 $sku = (string)($product['sku'] ?? '');
2040 if ($sku === '') {
2041 continue;
2042 }
2043 $matches[] = array(
2044 'product' => $product,
2045 'score' => $score,
2046 'sorter' => (int)($product['sorter'] ?? 100),
2047 'title' => (string)($product['title'] ?? ''),
2048 );
2049 }
2050
2051 if ($hasQuery && count($matches) > 1) {
2052 usort($matches, static function(array $a, array $b): int {
2053 if ($a['score'] !== $b['score']) {
2054 return $b['score'] <=> $a['score'];
2055 }
2056 if ($a['sorter'] !== $b['sorter']) {
2057 return $a['sorter'] <=> $b['sorter'];
2058 }
2059 return strcasecmp($a['title'], $b['title']);
2060 });
2061 }
2062
2063 $products = array_map(static fn($match) => $match['product'], $matches);
2064 $reportHtml = $products === array()
2065 ? $this->placeholder(
2066 $texts->get_fd_message('no_products_title'),
2067 $texts->get_fd_message('no_products_message')
2068 )
2069 : $this->catalogReportHtml($products, $channel, $query, $attributeFilters, $groupId);
2070
2071 $isPaginationAjax = (int)dbx()->get_system_var('dbx_ajax', 0, 'int') === 1
2072 && (array_key_exists('dbx_rpos', $_GET) || array_key_exists('dbx_rrows', $_GET));
2073 if ($isPaginationAjax) {
2074 return $reportHtml;
2075 }
2076
2077 $navigation = $this->catalogGroupBreadcrumb($groupId) . $this->catalogGroupNavigation($groupId);
2078 if ($navigation === '') {
2079 $navigation = $this->catalogGroupNavigation(0);
2080 }
2081 $title = is_array($currentGroup) ? (string)($currentGroup['title'] ?? 'Shop') : 'Shop';
2082 $subtitle = $texts->get_fd_message(
2083 is_array($currentGroup)
2084 ? 'catalog_group_subtitle'
2085 : 'catalog_subtitle'
2086 );
2087
2088 return $this->page(
2089 $title,
2090 $subtitle,
2091 $this->demoShopNoticeHtml(
2092 'dbx-shop-demo-catalog-notice',
2093 'dbx-shop-demo-alert-catalog',
2094 $texts
2095 )
2096 . $navigation
2097 . $this->catalogFiltersHtml($channel, $query, $attributeFilters, $groupId)
2098 . $reportHtml,
2099 'catalog'
2100 );
2101 }
2102
2103 public function product(): string {
2104 $this->ensureSeed();
2105 $texts = $this->texts('dbxShop|shop-catalog-filter-form');
2106 $channel = $this->activeChannel();
2107 $sku = dbx()->get_modul_var('sku', '', 'parameter');
2108 $product = $this->repo()->productBySku((string) $sku);
2109
2110 if (!$product || !$this->productHasChannel($product, $channel)) {
2111 return $this->page(
2112 $texts->get_fd_message('product_page_title'),
2113 $texts->get_fd_message('product_not_found_subtitle'),
2114 $this->placeholder(
2115 $texts->get_fd_message('product_not_found_title'),
2116 $texts->get_fd_message('product_not_found_message')
2117 ),
2118 'catalog'
2119 );
2120 }
2121
2122 $body = $this->renderProductDetail($product, $channel);
2123
2124 return $this->page(
2125 $product['title'] ?? $texts->get_fd_message('product_page_title'),
2126 $product['summary'] ?? $texts->get_fd_message('product_fallback'),
2127 $body,
2128 'catalog'
2129 );
2130 }
2131
2132 public function cart(): string {
2133 $this->ensureSeed();
2134 $texts = $this->texts('dbxShop|shop-cart');
2135 $channel = $this->activeChannel();
2136 $ajax = (int)dbx()->get_system_var('dbx_ajax', 0, 'int') === 1;
2137 $addSku = (string)dbx()->get_modul_var('sku', '', 'parameter');
2138 $cartReport = null;
2139 $hasCartPost = isset($_POST['shop_cart_update']) || isset($_POST['remove']) || isset($_POST['clear']);
2140
2141 if ($hasCartPost) {
2142 [$currentRows, $currentSum] = $this->cartReportDataAndSum($texts);
2143 if ($currentRows !== array()) {
2144 $cartReport = $this->cartReport($currentRows, $currentSum);
2145 if ($cartReport->submit() && !$cartReport->errors()) {
2146 $removeSku = (string)$cartReport->get_post('remove', '', 'parameter');
2147 $clear = (int)$cartReport->get_post('clear', 0, 'int') === 1;
2148 if ($clear) {
2149 $this->startSession();
2150 $_SESSION['dbxShop_cart'] = array();
2151 } elseif ($removeSku !== '') {
2152 $this->removeFromCart($removeSku);
2153 } elseif (isset($_POST['shop_cart_update']) && is_array($_POST['qty'] ?? null)) {
2154 // qty ist ein dynamisches Report-Feld. Der rohe Arraywert
2155 // wird erst nach erfolgreicher Report-Tokenprüfung gelesen;
2156 // updateCartQuantities begrenzt jeden Wert auf 1..999.
2157 $this->updateCartQuantities($_POST['qty']);
2158 }
2159 }
2160 }
2161 } elseif ($addSku !== '') {
2162 $product = $this->repo()->productBySku($addSku);
2163 $buyForm = is_array($product) ? $this->buyForm($product) : null;
2164 if ($buyForm && $buyForm->submit() && !$buyForm->errors()) {
2165 $qty = $this->requestedQuantity($buyForm->get_post('qty', 1, 'int|min=1'));
2166 $this->addToCart($addSku, $qty);
2167 return $this->page(
2168 $texts->get_fd_message('cart_title'),
2169 $texts->get_fd_message('added_subtitle'),
2170 $this->addedToCartDialog($product),
2171 'cart'
2172 );
2173 }
2174 }
2175
2176 $body = $this->cartBodyHtml($cartReport, $texts);
2177 if ($ajax) {
2178 return $body;
2179 }
2180
2181 return $this->page(
2182 $texts->get_fd_message('cart_title'),
2183 $texts->get_fd_message('cart_subtitle'),
2184 $body,
2185 'cart'
2186 );
2187 }
2188
2189 public function checkout(): string {
2190 $this->ensureSeed();
2191 $form = dbx()->get_system_obj('dbxForm');
2192 $form->init('shop-checkout-form', 'shop-checkout-form');
2193 $form->_fd = 'dbxShop|checkout';
2194 $form->load_fd_messages();
2195 [$rows, $sum] = $this->cartRowsAndSum();
2196 if ($rows === '') {
2197 return $this->page(
2198 $form->get_fd_message('page_title'),
2199 $form->get_fd_message('empty_subtitle'),
2200 $this->placeholder(
2201 $form->get_fd_message('empty_title'),
2202 $form->get_fd_message('empty_message')
2203 ),
2204 'checkout'
2205 );
2206 }
2207 $cfg = $this->shopConfig();
2208 if (!$this->settingsBool($cfg, 'checkout_guest_allowed', true) && (int)dbx()->user() <= 0) {
2209 return $this->page(
2210 $form->get_fd_message('page_title'),
2211 $form->get_fd_message('login_subtitle'),
2212 '<div class="alert alert-warning m-3">'
2213 . $form->get_fd_message('login_message')
2214 . '</div>',
2215 'checkout'
2216 );
2217 }
2218
2219 $paymentOptions = $this->checkoutPaymentOptions();
2220 $checkoutRequestId = $this->checkoutRequestId();
2221 $form->_action = '?dbx_modul=dbxShop&dbx_run1=checkout';
2222 $form->_data = array_merge($form->_data, array(
2223 'customer_name' => (string)($_POST['customer_name'] ?? ''),
2224 'customer_email' => (string)($_POST['customer_email'] ?? ''),
2225 'customer_phone' => (string)($_POST['customer_phone'] ?? ''),
2226 'shipping_address' => (string)($_POST['shipping_address'] ?? ''),
2227 'note' => (string)($_POST['note'] ?? ''),
2228 'checkout_request_id' => $checkoutRequestId,
2229 'payment_method' => (string)($_POST['payment_method'] ?? array_key_first($paymentOptions)),
2230 'accept_legal' => !empty($_POST['accept_legal']) ? 1 : 0,
2231 'accept_withdrawal' => !empty($_POST['accept_withdrawal']) ? 1 : 0,
2232 ));
2233 $form->_msg_info = $form->get_fd_message('form_info');
2234 $form->_msg_error = $form->get_fd_message('validation_error');
2235 $form->add_flds();
2236 $form->add_obj('checkout_cart', 'obj-value', $this->checkoutTableHtml($rows, $sum));
2237 $form->add_rep('payment_help', $this->checkoutPaymentHelp($paymentOptions));
2238 $form->add_rep(
2239 'demo_shop_notice',
2240 $this->demoShopNoticeHtml('dbx-shop-demo-notice', '', $form)
2241 );
2242
2243 if ($form->submit()) {
2244 if ($form->errors()) {
2245 $form->_msg_error = $form->get_fd_message(
2246 'validation_error'
2247 );
2248 } else {
2249 $paymentMethod = (string)$form->get_post('payment_method', '', 'parameter');
2250 $customerName = trim((string)$form->get_post_data('customer_name', '', '*|min=2|max=180'));
2251 $customerEmail = trim((string)$form->get_post('customer_email', '', 'email|max=180'));
2252 $shippingAddress = trim((string)$form->get_post_data('shipping_address', '', '*|min=8|max=2000'));
2253 $customerPhone = trim((string)$form->get_post_data('customer_phone', '', '*|max=80'));
2254 $note = trim((string)$form->get_post_data('note', '', '*|max=2000'));
2255 $acceptLegal = (int)$form->get_post('accept_legal', 0, 'int') === 1;
2256 $acceptWithdrawal = (int)$form->get_post('accept_withdrawal', 0, 'int') === 1;
2257
2258 $checkoutError = '';
2259 if ($paymentOptions === array()) {
2260 $checkoutError = $form->get_fd_message('no_payment');
2261 $form->add_fld_error('payment_method', $checkoutError);
2262 } elseif (!isset($paymentOptions[$paymentMethod])) {
2263 $checkoutError = $form->get_fd_message('select_payment');
2264 $form->add_fld_error('payment_method', $checkoutError);
2265 } elseif (!$acceptLegal || !$acceptWithdrawal) {
2266 $checkoutError = $form->get_fd_message('confirm_legal');
2267 if (!$acceptLegal) {
2268 $form->add_fld_error(
2269 'accept_legal',
2270 $form->get_fd_message('legal_field_error')
2271 );
2272 }
2273 if (!$acceptWithdrawal) {
2274 $form->add_fld_error(
2275 'accept_withdrawal',
2276 $form->get_fd_message('withdrawal_field_error')
2277 );
2278 }
2279 }
2280
2281 if ($checkoutError !== '') {
2282 $form->_msg_error = $checkoutError;
2283 } else {
2284 try {
2285 $requestId = $checkoutRequestId;
2286 $existingOrder = $this->checkoutRequestOrder($requestId);
2287 if (is_array($existingOrder)) {
2288 return $this->continueCheckoutOrder($existingOrder, $paymentMethod);
2289 }
2290
2291 [$legalSnapshot, $withdrawalSnapshot] = $this->legalSnapshotsForOrder();
2292 $order = $this->repo()->createOrderFromItems(
2293 $this->cartItems(),
2294 $this->activeChannel(),
2295 $customerName,
2296 $customerEmail,
2297 $note,
2298 $paymentMethod,
2299 in_array($paymentMethod, array('paypal', 'amazon_pay'), true) ? 'created' : 'open',
2300 'payment_pending',
2301 $customerPhone,
2302 $shippingAddress,
2303 $legalSnapshot,
2304 $withdrawalSnapshot
2305 );
2306 if (!$order) {
2307 $form->_msg_error = $form->get_fd_message(
2308 'order_error'
2309 );
2310 } else {
2311 $this->rememberCheckoutRequest($requestId, $order);
2312 return $this->continueCheckoutOrder($order, $paymentMethod);
2313 }
2314 } catch (\Throwable $e) {
2315 dbx()->sys_msg('error', 'dbxShop', 'checkout', 'checkout failed', $e->getMessage());
2316 $form->_msg_error = $form->get_fd_message(
2317 'technical_error'
2318 );
2319 }
2320 }
2321 }
2322 }
2323
2324 return $this->page(
2325 $form->get_fd_message('page_title'),
2326 $form->get_fd_message('page_subtitle'),
2327 $form->run(),
2328 'checkout'
2329 );
2330 }
2331
2332 public function paypalStart(): string {
2333 $this->ensureSeed();
2334 return $this->checkout();
2335 }
2336
2342 private function providerReturnOrder(string $provider, string $reference, string $orderNo): ?array {
2343 if ($reference === '') return null;
2344 $order = $this->repo()->orderByPaymentReference($provider, $reference);
2345 if (!is_array($order)) return null;
2346 if ($orderNo !== '' && !hash_equals((string)($order['order_no'] ?? ''), $orderNo)) {
2347 return null;
2348 }
2349 return $order;
2350 }
2351
2352 private function rememberProviderOrder(array $order, bool $clearCart = true): void {
2353 $this->startSession();
2354 if ($clearCart) $_SESSION['dbxShop_cart'] = array();
2355 $_SESSION['dbxShop_last_order_no'] = (string)($order['order_no'] ?? '');
2356 }
2357
2358 public function paypalReturn(): string {
2359 $this->ensureSeed();
2360 $paypalOrderId = (string)dbx()->get_modul_var('token', '', 'parameter');
2361 $orderNo = (string)dbx()->get_modul_var('order_no', '', 'parameter');
2362 $order = $this->providerReturnOrder('paypal', $paypalOrderId, $orderNo);
2363 if (!$order || $paypalOrderId === '') {
2364 return $this->page('PayPal', 'Rueckkehr konnte nicht zugeordnet werden.', '<div class="alert alert-danger m-3">Die PayPal-Rueckkehr konnte keiner Bestellung zugeordnet werden.</div>', 'checkout');
2365 }
2366
2367 if (in_array((string)($order['payment_status'] ?? ''), array('completed', 'paid'), true)) {
2368 $this->rememberProviderOrder($order);
2369 $body = '<div class="alert alert-success m-3"><strong>Zahlung bereits abgeschlossen.</strong><br>Bestellung ' . $this->h($order['order_no'] ?? '') . ' ist bezahlt.</div>';
2370 return $this->page('Danke', 'PayPal-Zahlung wurde bestaetigt.', $body, 'orders');
2371 }
2372 if (!$this->repo()->claimOrderPayment((int)$order['id'], 'paypal', $paypalOrderId)) {
2373 $fresh = $this->repo()->orderByPaymentReference('paypal', $paypalOrderId) ?: $order;
2374 $body = '<div class="alert alert-info m-3"><strong>Zahlung wird bereits verarbeitet.</strong><br>Bestellung ' . $this->h($fresh['order_no'] ?? '') . ' wird nicht doppelt belastet.</div>';
2375 return $this->page('PayPal', 'Zahlungsstatus wird verarbeitet.', $body, 'orders');
2376 }
2377
2378 try {
2379 $capture = $this->paypal()->capture($paypalOrderId);
2380 $this->paypal()->validateCapture($capture, $order, $paypalOrderId);
2381 if (!$this->repo()->updateOrderPayment((int)$order['id'], 'paypal', 'completed', $paypalOrderId, $capture)) {
2382 throw new \RuntimeException('PayPal-Zahlungsstatus konnte nicht atomar gespeichert werden.');
2383 }
2384 $freshOrder = $this->repo()->orderById((int)$order['id']) ?: $order;
2385 $this->sendOrderMails($freshOrder);
2386 $this->rememberProviderOrder($freshOrder);
2387 $body = '<div class="alert alert-success m-3"><strong>Zahlung abgeschlossen.</strong><br>Bestellung ' . $this->h($order['order_no'] ?? '') . ' wurde bezahlt.</div>';
2388 return $this->page('Danke', 'PayPal-Zahlung wurde bestaetigt.', $body, 'orders');
2389 } catch (\Throwable $e) {
2390 $this->repo()->updateOrderPayment((int)$order['id'], 'paypal', 'failed', $paypalOrderId, array('error' => $e->getMessage()));
2391 return $this->page('PayPal', 'Zahlung konnte nicht bestaetigt werden.', '<div class="alert alert-danger m-3">' . $this->h($e->getMessage()) . '</div>', 'checkout');
2392 }
2393 }
2394
2395 public function paypalCancel(): string {
2396 return $this->page(
2397 'PayPal abgebrochen',
2398 'Die Zahlung wurde nicht ausgefuehrt.',
2399 '<div class="alert alert-info m-3">Die PayPal-Seite wurde verlassen. Der Warenkorb bleibt erhalten; der serverseitige Zahlungsstatus wird nicht allein aufgrund dieses Browseraufrufs veraendert.</div>',
2400 'checkout'
2401 );
2402 }
2403
2404 public function amazonPayReturn(): string {
2405 $this->ensureSeed();
2406 $checkoutSessionId = (string)dbx()->get_modul_var('checkoutSessionId', '', 'parameter');
2407 if ($checkoutSessionId === '') {
2408 $checkoutSessionId = (string)dbx()->get_modul_var('amazonCheckoutSessionId', '', 'parameter');
2409 }
2410 $orderNo = (string)dbx()->get_modul_var('order_no', '', 'parameter');
2411 $order = $this->providerReturnOrder('amazon_pay', $checkoutSessionId, $orderNo);
2412 if (!$order || $checkoutSessionId === '') {
2413 return $this->page('Amazon Pay', 'Rueckkehr konnte nicht zugeordnet werden.', '<div class="alert alert-danger m-3">Die Amazon-Pay-Rueckkehr konnte keiner Bestellung zugeordnet werden.</div>', 'checkout');
2414 }
2415
2416 if (in_array((string)($order['payment_status'] ?? ''), array('completed', 'paid', 'pending'), true)) {
2417 $this->rememberProviderOrder($order);
2418 $body = '<div class="alert alert-success m-3"><strong>Amazon-Pay-Zahlung bereits verarbeitet.</strong><br>Bestellung ' . $this->h($order['order_no'] ?? '') . ' besitzt bereits einen gueltigen Providerstatus.</div>';
2419 return $this->page('Danke', 'Amazon-Pay-Zahlung wurde verarbeitet.', $body, 'orders');
2420 }
2421 if (!$this->repo()->claimOrderPayment((int)$order['id'], 'amazon_pay', $checkoutSessionId)) {
2422 $fresh = $this->repo()->orderByPaymentReference('amazon_pay', $checkoutSessionId) ?: $order;
2423 $body = '<div class="alert alert-info m-3"><strong>Zahlung wird bereits verarbeitet.</strong><br>Bestellung ' . $this->h($fresh['order_no'] ?? '') . ' wird nicht doppelt abgeschlossen.</div>';
2424 return $this->page('Amazon Pay', 'Zahlungsstatus wird verarbeitet.', $body, 'orders');
2425 }
2426
2427 try {
2428 $result = $this->amazonPay()->completeCheckoutSession($checkoutSessionId, $order);
2429 $paymentStatus = $this->amazonPay()->validateCompletion($result, $order, $checkoutSessionId);
2430 if (!$this->repo()->updateOrderPayment((int)$order['id'], 'amazon_pay', $paymentStatus, $checkoutSessionId, $result)) {
2431 throw new \RuntimeException('Amazon-Pay-Status konnte nicht atomar gespeichert werden.');
2432 }
2433 $freshOrder = $this->repo()->orderById((int)$order['id']) ?: $order;
2434 if ($paymentStatus === 'completed') {
2435 $this->sendOrderMails($freshOrder);
2436 }
2437 $this->rememberProviderOrder($freshOrder);
2438 $body = '<div class="alert alert-success m-3"><strong>Amazon-Pay-Zahlung verarbeitet.</strong><br>Bestellung ' . $this->h($order['order_no'] ?? '') . ' wurde aktualisiert.</div>';
2439 return $this->page('Danke', 'Amazon-Pay-Zahlung wurde bestaetigt.', $body, 'orders');
2440 } catch (\Throwable $e) {
2441 $this->repo()->updateOrderPayment((int)$order['id'], 'amazon_pay', 'failed', $checkoutSessionId, array('error' => $e->getMessage()));
2442 return $this->page('Amazon Pay', 'Zahlung konnte nicht bestaetigt werden.', '<div class="alert alert-danger m-3">' . $this->h($e->getMessage()) . '</div>', 'checkout');
2443 }
2444 }
2445
2446 public function amazonPayCancel(): string {
2447 return $this->page(
2448 'Amazon Pay abgebrochen',
2449 'Die Zahlung wurde nicht ausgefuehrt.',
2450 '<div class="alert alert-info m-3">Die Amazon-Pay-Seite wurde verlassen. Der Warenkorb bleibt erhalten; der serverseitige Zahlungsstatus wird nicht allein aufgrund dieses Browseraufrufs veraendert.</div>',
2451 'checkout'
2452 );
2453 }
2454
2455 public function orders(): string {
2456 $this->ensureSeed();
2457 $this->startSession();
2458 $texts = $this->texts('dbxShop|shop-orders');
2459 $cards = '';
2460 $seen = array();
2461 $lastOrderNo = (string)($_SESSION['dbxShop_last_order_no'] ?? '');
2462 if ($lastOrderNo !== '') {
2463 $order = $this->repo()->orderByNo($lastOrderNo);
2464 if (is_array($order)) {
2465 $cards .= $this->publicOrderCard($order);
2466 $seen[(string)($order['order_no'] ?? '')] = true;
2467 }
2468 }
2469
2470 $uid = function_exists('dbx') ? (int)dbx()->user() : 0;
2471 foreach ($this->repo()->ordersByUid($uid, 25) as $order) {
2472 $orderNo = (string)($order['order_no'] ?? '');
2473 if ($orderNo !== '' && isset($seen[$orderNo])) {
2474 continue;
2475 }
2476 $cards .= $this->publicOrderCard($order);
2477 }
2478
2479 if ($cards === '') {
2480 $cards = $this->placeholder(
2481 $texts->get_fd_message('empty_title'),
2482 $texts->get_fd_message('empty_message')
2483 );
2484 } else {
2485 $cards = '<section class="dbx-shop-public-orders">' . $cards . '</section>';
2486 }
2487
2488 return $this->page(
2489 $texts->get_fd_message('page_title'),
2490 $texts->get_fd_message('page_subtitle'),
2491 $cards,
2492 'orders'
2493 );
2494 }
2495
2496 private function orderIsPublicAccessible(array $order): bool {
2497 $this->startSession();
2498 $orderNo = (string)($order['order_no'] ?? '');
2499 if ($orderNo !== '' && $orderNo === (string)($_SESSION['dbxShop_last_order_no'] ?? '')) {
2500 return true;
2501 }
2502 $uid = function_exists('dbx') ? (int)dbx()->user() : 0;
2503 return $uid > 0 && (int)($order['uid'] ?? 0) === $uid;
2504 }
2505
2506 public function invoicePdf(): string {
2507 $this->ensureSeed();
2508 $this->startSession();
2509 $orderNo = (string)dbx()->get_modul_var('order_no', '', 'parameter');
2510 $order = $orderNo !== '' ? $this->repo()->orderByNo($orderNo) : null;
2511 if (!is_array($order) || !$this->orderIsPublicAccessible($order)) {
2512 return $this->page('Rechnung', 'Zugriff nicht moeglich.', '<div class="alert alert-warning m-3">Die Rechnung wurde nicht gefunden oder ist fuer diesen Benutzer nicht freigegeben.</div>', 'orders');
2513 }
2514 $order = $this->repo()->ensureOrderInvoicePdf((int)$order['id']);
2515 if (!is_array($order)) {
2516 return $this->page('Rechnung', 'PDF konnte nicht erzeugt werden.', '<div class="alert alert-danger m-3">Die Rechnungsdatei konnte nicht erzeugt werden.</div>', 'orders');
2517 }
2518 $file = $this->repo()->invoicePdfAbsolutePath($order);
2519 if ($file === '') {
2520 return $this->page('Rechnung', 'PDF konnte nicht geladen werden.', '<div class="alert alert-danger m-3">Die Rechnungsdatei ist nicht verfuegbar.</div>', 'orders');
2521 }
2522 if (!headers_sent()) {
2523 header('Content-Type: application/pdf');
2524 header('Content-Disposition: inline; filename="' . basename($file) . '"');
2525 header('Content-Length: ' . filesize($file));
2526 }
2527 readfile($file);
2528 exit;
2529 }
2530
2531 private function jsonResponse(array $data, int $status = 200): string {
2532 if (!headers_sent()) {
2533 http_response_code($status);
2534 header('Content-Type: application/json; charset=utf-8');
2535 }
2536 return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
2537 }
2538
2539 public function channelWebhook(): string {
2540 $channelKey = (string)dbx()->get_modul_var('channel', '', 'parameter');
2541 $raw = (string)file_get_contents('php://input');
2542 $payload = json_decode($raw, true);
2543 if (!is_array($payload)) {
2544 $payload = $_POST;
2545 }
2546
2547 try {
2548 $channel = $this->repo()->channelByKey($channelKey);
2549 if (!$channel) {
2550 return $this->jsonResponse(array('ok' => false, 'message' => 'Channel nicht gefunden.'), 404);
2551 }
2552 if ((int)($channel['active'] ?? 0) !== 1 || (int)($channel['order_import_enabled'] ?? 0) !== 1) {
2553 return $this->jsonResponse(array('ok' => false, 'message' => 'Order-Import fuer diesen Channel ist nicht aktiv.'), 403);
2554 }
2555
2556 $secret = trim((string)($channel['webhook_secret'] ?? ''));
2557 if ($secret === '') {
2558 // Der Endpunkt ist wegen externer Provider bewusst oeffentlich.
2559 // Modul-/DD-Rechte authentifizieren deshalb keinen Absender.
2560 return $this->jsonResponse(array(
2561 'ok' => false,
2562 'message' => 'Webhook-Authentifizierung ist nicht konfiguriert.',
2563 ), 503);
2564 }
2565 // Keine Secrets in GET-URLs: Query-Strings landen regelmaessig in
2566 // Access-Logs, Browser-Historien und Referrer-Headern.
2567 $given = trim((string)(
2568 $_SERVER['HTTP_X_DBX_SHOP_SECRET']
2569 ?? $_SERVER['HTTP_X_CHANNEL_SECRET']
2570 ?? $_POST['secret']
2571 ?? $payload['secret']
2572 ?? ''
2573 ));
2574 if ($given === '' || !hash_equals($secret, $given)) {
2575 return $this->jsonResponse(array('ok' => false, 'message' => 'Webhook-Secret ungueltig.'), 403);
2576 }
2577
2578 $connector = dbx()->get_include_obj('dbxShopChannelConnector', 'dbxShop');
2579 if (is_object($connector) && method_exists($connector, 'normalizeWebhookPayload')) {
2580 $payload = (array)$connector->normalizeWebhookPayload($channel, $payload);
2581 }
2582
2583 $order = $this->repo()->importChannelOrder($channelKey, $payload);
2584 return $this->jsonResponse(array(
2585 'ok' => true,
2586 'order_no' => (string)($order['order_no'] ?? ''),
2587 'channel' => $channelKey,
2588 ));
2589 } catch (\Throwable $e) {
2590 return $this->jsonResponse(array('ok' => false, 'message' => $e->getMessage()), 400);
2591 }
2592 }
2593
2594 public function legal(): string {
2595 return $this->renderCmsShopPage(
2596 'legal',
2597 'Rechtstexte',
2598 'AGB, Anbieterkennzeichnung, Zahlung, Versand und Datenschutz-Hinweise.',
2599 'legal'
2600 );
2601 }
2602
2603 public function withdrawal(): string {
2604 $form = dbx()->get_system_obj('dbxForm');
2605 $form->init('shop-withdrawal-form', 'shop-withdrawal-form');
2606 $form->_fd = 'dbxShop|withdrawal';
2607 $form->load_fd_messages();
2608 $pages = $this->ensureShopLegalPages();
2609 $cid = (int)($pages['withdrawal'] ?? 0);
2610 $body = '';
2611 if ($cid > 0) {
2612 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
2613 $body = is_object($renderer) ? (string)$renderer->renderStatic($cid, array('template' => 'c-body1-footer')) : '';
2614 }
2615 if (trim($body) === '') {
2616 $body = $this->placeholder(
2617 $form->get_fd_message('page_title'),
2618 $form->get_fd_message('empty_content')
2619 );
2620 }
2621 return $this->page(
2622 $form->get_fd_message('page_title'),
2623 $form->get_fd_message('page_subtitle'),
2624 '<div class="dbx-shop-cms-page">' . $body . '</div>' . $this->withdrawalFormHtml($form),
2625 'withdrawal'
2626 );
2627 }
2628}
2629?>
$paymentOptions
$cfg
static ddContent(string $lng='')
static afterPageSave($db, int $id, bool $isNew=false)
static afterFolderSave($db, int $id, bool $isNew=false)
if((string)($formActionPolicy['action'] ?? '') !=='dbxAction.save'||!dbx() ->check_action_token((string)($formActionPolicy['scope'] ?? ''),(string)($formActionRoute['params']['dbx_token'] ?? ''))) $report
user($key='id')
Liest einen Wert des aktuellen Benutzers.
Definition dbxApi.php:1305
dbx()
Liefert die zentrale dbXapp-API als Singleton.
Definition dbxApi.php:2805
send_mail($from, $to, string $subject='', string $body='', string $format='html', $attachments=array(), array $options=array())
Sendet eine E-Mail ueber dbxMail/PHPMailer.
Definition dbxApi.php:940
$_SERVER['REQUEST_METHOD']
if(!is_object( $db)) if($db->connect_db_server($server) !==1) $created
if(preg_match('/core\.js\? foreach[^"\']*v=(\d+)/', $designTemplate, $assetMatch) !== 1 || !str_contains($shopReference, 'dbxapp-Asset-Version ' . $assetMatch[1])) foreach (array( 'reference\\archive', 'provision_docs_content.php', 'dbxSelfTest') as $needle)(array('Installation'=> $installation, 'Installations-Tutorial'=> $installationTutorial, 'SelfTest'=> $selfTest) as $label=> $html)
if(strpos($template, 'Module bearbeiten und aktualisieren')===false||strpos($template, 'Handbuch+Referenz')===false) $instructions
foreach(array( 'forms', 'reports', 'dd', 'db', 'audit', 'objects', 'escaping', 'get', 'action_links') as $requiredKey) foreach(array('{fid}_{event}-Callback-Defaults', 'add_rep()', '{rpt:colspan}') as $requiredReportRule) $reference
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
foreach( $valid as $email) foreach($invalid as $email) $fd
exit
Definition index.php:146
if( $demoId<=0) foreach(array('create_date', 'create_uid', 'update_date', 'update_uid', 'owner',) as $systemField) $items
foreach(array('Positionen zu Rechnung DBX-DEMO-1001', '39, 80 EUR', '7, 50 EUR', '47, 30 EUR', 'Endsumme der Positionen',) as $expected) $reportHtml
DBX schema administration.
$profile
Definition run.php:6