dbXapp 2.0
RAD, CMS, Module und Runtime-IDE fuer dbXapp
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 function tpl() {
9 return dbx()->get_system_obj('dbxTPL');
10 }
11
12 private function repo(): dbxShopRepository {
13 return dbx()->get_include_obj('dbxShopRepository', 'dbxShop');
14 }
15
16 private function paypal(): dbxShopPayPal {
17 return dbx()->get_include_obj('dbxShopPayPal', 'dbxShop');
18 }
19
20 private function amazonPay(): dbxShopAmazonPay {
21 return dbx()->get_include_obj('dbxShopAmazonPay', 'dbxShop');
22 }
23
24 private function h($value): string {
25 return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
26 }
27
28 private function money($value): string {
29 return number_format((float) $value, 2, ',', '.') . ' EUR';
30 }
31
32 private function shopConfig(): array {
33 $cfg = dbx()->get_config('dbxShop');
34 return is_array($cfg) ? $cfg : array();
35 }
36
37 private function settingsBool(array $cfg, string $key, bool $default = false): bool {
38 if (!array_key_exists($key, $cfg)) {
39 return $default;
40 }
41 $value = $cfg[$key];
42 if (is_bool($value)) return $value;
43 return in_array(strtolower(trim((string)$value)), array('1', 'true', 'yes', 'on'), true);
44 }
45
46 private function shopStyle(): string {
47 $file = dirname(__DIR__) . '/design/css/shop.css';
48 if (!is_file($file)) {
49 return '';
50 }
51 return '<style>' . file_get_contents($file) . '</style>';
52 }
53
54 private function demoShopNoticeHtml(string $id = '', string $extraClass = ''): string {
55 $idAttribute = $id !== '' ? ' id="' . $this->h($id) . '"' : '';
56 $classAttribute = $extraClass !== '' ? ' ' . $this->h($extraClass) : '';
57 return '<div' . $idAttribute . ' class="alert alert-danger dbx-shop-demo-alert' . $classAttribute . '" role="alert">'
58 . '<strong><i class="bi bi-exclamation-octagon-fill"></i> Demo-Shop &ndash; kein tats&auml;chlicher Kauf</strong><br>'
59 . 'Dieser Shop dient ausschlie&szlig;lich Demonstrations- und Testzwecken. Der vollst&auml;ndige Bestellablauf kann mit Testdaten durchlaufen werden; dabei wird lediglich ein technischer Testvorgang verarbeitet. Es erfolgen kein tats&auml;chlicher Kauf, keine Zahlung und keine Lieferung. Ein Kaufvertrag kommt nicht zustande.'
60 . '</div>';
61 }
62
63 private function page(string $title, string $subtitle, string $body, string $active = 'catalog'): string {
64 return $this->tpl()->get_tpl('dbxShop|start', array(
65 'shop_style' => $this->shopStyle(),
66 'title' => $this->h($title),
67 'subtitle' => $this->h($subtitle),
68 'body' => $body,
69 'active_catalog' => $active === 'catalog' ? 'active' : '',
70 'active_cart' => $active === 'cart' ? 'active' : '',
71 'active_checkout' => $active === 'checkout' ? 'active' : '',
72 'active_orders' => $active === 'orders' ? 'active' : '',
73 'active_legal' => $active === 'legal' ? 'active' : '',
74 'active_withdrawal' => $active === 'withdrawal' ? 'active' : '',
75 ));
76 }
77
78 private function contentDb() {
79 $db = dbx()->get_system_obj('dbxDB');
80 if (!is_object($db) || !$db->connect_db_server('dbx|dbxContent.db3')) {
81 return null;
82 }
83 return $db;
84 }
85
86 private function findContentFolder($db, string $name, int $parentId): int {
87 $name = trim($name);
88 $parentId = (int) $parentId;
89 if ($name === '') {
90 return 0;
91 }
92 $where = "name = '" . str_replace("'", "''", $name) . "' AND parent_id = " . $parentId;
93 $rows = $db->select(\dbx\dbxContent\dbxContentLng::ddFolder(), $where, 'id', 'id', 'ASC', '', 1, 0, 0);
94 if (!is_array($rows) || !isset($rows[0]['id'])) {
95 return 0;
96 }
97 return (int) $rows[0]['id'];
98 }
99
100 private function nextFolderSorter($db, int $parentId): string {
101 $parentId = (int) $parentId;
102 $rows = $db->select(\dbx\dbxContent\dbxContentLng::ddFolder(), 'parent_id = ' . $parentId, 'sorter', 'sorter,id', 'DESC', '', 1, 0, 0);
103 $max = 0;
104 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
105 $max = (int) ($rows[0]['sorter'] ?? 0);
106 }
107 return sprintf('%04d', $max + 10);
108 }
109
110 private function nextContentSorter($db, int $folderId): string {
111 $folderId = (int) $folderId;
112 $rows = $db->select(\dbx\dbxContent\dbxContentLng::ddContent(), 'folder = ' . $folderId, 'sorter', 'sorter,id', 'DESC', '', 1, 0, 0);
113 $max = 0;
114 if (is_array($rows) && isset($rows[0]) && is_array($rows[0])) {
115 $max = (int) ($rows[0]['sorter'] ?? 0);
116 }
117 return sprintf('%04d', $max + 10);
118 }
119
120 private function ensureShopContentFolder($db): int {
121 $folderId = $this->findContentFolder($db, 'shop', 0);
122 if ($folderId > 0) {
123 return $folderId;
124 }
125
126 $data = array(
127 'name' => 'shop',
128 'parent_id' => 0,
129 'sorter' => $this->nextFolderSorter($db, 0),
130 'group_read' => '*',
131 'template' => 'c-body1-footer',
132 'hero_template' => 'image-hero',
133 'hero_image_id' => 'parent',
134 'hero_margin_top' => 'parent',
135 'hero_height' => 'parent',
136 'hero_variant' => 'parent',
137 'hero_sticky' => 'parent',
138 'hero_scroll_layer' => 'parent',
139 );
140 $ok = (int) $db->insert(\dbx\dbxContent\dbxContentLng::ddFolder(), $data, 0, 1, 0, 0);
141 if ($ok !== 1) {
142 return 0;
143 }
144 $folderId = (int) $db->get_insert_id();
145 if ($folderId <= 0) {
146 return 0;
147 }
148
149 if ($folderId > 0) {
151 }
152 return $folderId;
153 }
154
155 private function shopLegalPageData($db, int $folderId, string $title, string $permalink, string $content): array {
156 return array(
157 'activ' => 1,
158 'folder' => $folderId,
159 'title' => substr($title, 0, 254),
160 'permalink' => substr($permalink, 0, 254),
161 'description' => '',
162 'keywords' => '',
163 'group_read' => '*',
164 'sorter' => $this->nextContentSorter($db, $folderId),
165 'template' => 'c-body1-footer',
166 'hero_template' => 'parent',
167 'hero_image_id' => 'parent',
168 'hero_margin_top' => 'parent',
169 'hero_height' => 'parent',
170 'hero_variant' => 'parent',
171 'hero_sticky' => 'parent',
172 'hero_scroll_layer' => 'parent',
173 'gallery_template' => 'image-gallery',
174 'gallery_visible_count' => '3',
175 'gallery_image_size' => 'original',
176 'gallery_lightbox_width' => '100vw',
177 'gallery_overflow' => 'grid',
178 'gallery_click_behavior' => 'lightbox',
179 'content' => $content,
180 );
181 }
182
183 private function syncContentPermalink(int $cid, string $permalink): void {
184 if ($cid <= 0 || trim($permalink) === '') {
185 return;
186 }
188 }
189
190 private function ensureShopLegalPage($db, int $folderId, string $title, string $permalink, string $content): int {
192 $existing = $db->select1($dd, array('permalink' => $permalink), 'id,content', 0);
193 if (is_array($existing) && (int) ($existing['id'] ?? 0) > 0) {
194 $id = (int) $existing['id'];
195 $storedContent = trim((string) ($existing['content'] ?? ''));
196 if ($storedContent === '') {
197 $data = $this->shopLegalPageData($db, $folderId, $title, $permalink, $content);
198 unset($data['sorter']);
199 $db->update($dd, $data, $id, 0, 1, 1, 0);
201 } else {
202 $db->update($dd, array('template' => 'c-body1-footer', 'group_read' => '*', 'activ' => 1), $id, 0, 1, 1, 0);
203 }
204 $this->syncContentPermalink($id, $permalink);
205 return $id;
206 }
207
208 $data = $this->shopLegalPageData($db, $folderId, $title, $permalink, $content);
209 $ok = (int) $db->insert($dd, $data, 0, 1, 0, 0);
210 if ($ok !== 1) {
211 return 0;
212 }
213 $id = (int) $db->get_insert_id();
214 if ($id <= 0) {
215 return 0;
216 }
217 if ($id > 0) {
219 $this->syncContentPermalink($id, $permalink);
220 }
221 return $id;
222 }
223
224 public function ensureShopLegalPages(): array {
225 $db = $this->contentDb();
226 if (!is_object($db)) {
227 return array();
228 }
229 $folderId = $this->ensureShopContentFolder($db);
230 if ($folderId <= 0) {
231 return array();
232 }
233
234 return array(
235 'legal' => $this->ensureShopLegalPage($db, $folderId, 'Rechtstexte', 'shop/rechtstexte', $this->defaultLegalContent()),
236 'withdrawal' => $this->ensureShopLegalPage($db, $folderId, 'Widerruf', 'shop/widerruf', $this->defaultWithdrawalContent()),
237 );
238 }
239
240 private function renderCmsShopPage(string $key, string $title, string $subtitle, string $active): string {
241 $pages = $this->ensureShopLegalPages();
242 $cid = (int) ($pages[$key] ?? 0);
243 if ($cid <= 0) {
244 return $this->page($title, $subtitle, $this->placeholder($title, 'Die CMS-Seite konnte nicht angelegt oder geladen werden.'), $active);
245 }
246
247 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
248 $body = is_object($renderer) ? (string) $renderer->renderStatic($cid, array('template' => 'c-body1-footer')) : '';
249 if (trim($body) === '') {
250 $body = $this->placeholder($title, 'Die CMS-Seite ist leer.');
251 }
252 return $this->page($title, $subtitle, '<div class="dbx-shop-cms-page">' . $body . '</div>', $active);
253 }
254
255 private function withdrawalFormHtml(): string {
256 $form = dbx()->get_system_obj('dbxForm');
257 $form->init('shop-withdrawal-form', 'shop-withdrawal-form');
258 $form->_fd = 'dbxShop|withdrawal';
259 $form->_action = '?dbx_modul=dbxShop&dbx_run1=withdrawal';
260 $form->_data = array(
261 'order_no' => (string)($_POST['order_no'] ?? ''),
262 'customer_name' => (string)($_POST['customer_name'] ?? ''),
263 'customer_email' => (string)($_POST['customer_email'] ?? ''),
264 'customer_address' => (string)($_POST['customer_address'] ?? ''),
265 'reason' => (string)($_POST['reason'] ?? ''),
266 );
267 $form->_msg_info = 'Bitte Bestellnummer und Kontaktdaten eintragen, damit der Widerruf zugeordnet werden kann.';
268 $form->_msg_success = 'Widerruf wurde gespeichert.';
269 $form->_msg_error = 'Bitte Eingaben pruefen.';
270 $form->add_flds();
271 $form->add_rep('withdrawal_error', '');
272
273 $submitted = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['shop_withdrawal_submit']);
274 if ($submitted) {
275 $form->submit();
276 $name = trim((string)($_POST['customer_name'] ?? ''));
277 $email = trim((string)($_POST['customer_email'] ?? ''));
278 $address = trim((string)($_POST['customer_address'] ?? ''));
279 if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($address) < 8) {
280 $form->add_rep('withdrawal_error', '<div class="alert alert-warning mb-0">Bitte Name, gueltige E-Mail und Adresse eintragen.</div>');
281 } else {
282 $row = $this->repo()->saveWithdrawal($_POST);
283 if (is_array($row)) {
284 $this->sendWithdrawalMails($row);
285 $form->_data = array('order_no' => '', 'customer_name' => '', 'customer_email' => '', 'customer_address' => '', 'reason' => '');
286 $form->add_rep('withdrawal_error', '<div class="alert alert-success mb-0">Ihr Widerruf wurde gespeichert. Wir pruefen die Zuordnung zur Bestellung.</div>');
287 } else {
288 $form->add_rep('withdrawal_error', '<div class="alert alert-danger mb-0">Der Widerruf konnte nicht gespeichert werden.</div>');
289 }
290 }
291 }
292
293 return $form->run();
294 }
295
296 private function defaultLegalContent(): string {
297 return <<<'HTML'
298<div class="dbx-shop-legal-text">
299 <h1>Rechtstexte</h1>
300 <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>
301
302 <h2>Anbieterkennzeichnung</h2>
303 <p><strong>[Name/Firma des Shop-Betreibers]</strong><br>
304 [Strasse und Hausnummer]<br>
305 [PLZ Ort]<br>
306 Deutschland</p>
307 <p>Vertreten durch: [vertretungsberechtigte Person]<br>
308 E-Mail: <a href="mailto:[E-Mail-Adresse]">[E-Mail-Adresse]</a><br>
309 Telefon: [Telefonnummer]</p>
310 <p>Registereintrag: [Registergericht und Registernummer, falls vorhanden]<br>
311 Umsatzsteuer-ID: [USt-IdNr., falls vorhanden]<br>
312 Wirtschafts-ID: [W-IdNr., falls vorhanden]</p>
313 <p>Zustaendige Aufsichtsbehoerde: [nur eintragen, wenn fuer die Taetigkeit erforderlich]</p>
314
315 <h2>Geltungsbereich</h2>
316 <p>Diese Rechtstexte gelten fuer Bestellungen ueber diesen Shop. Abweichende Bedingungen von Kunden gelten nur, wenn der Shop-Betreiber ihnen ausdruecklich zustimmt.</p>
317
318 <h2>Vertragspartner und Vertragsschluss</h2>
319 <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>
320
321 <h2>Preise, Zahlung und Rechnung</h2>
322 <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>
323
324 <h2>Lieferung und Versand</h2>
325 <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>
326
327 <h2>Eigentumsvorbehalt</h2>
328 <p>Gelieferte Waren bleiben bis zur vollstaendigen Bezahlung Eigentum des Shop-Betreibers.</p>
329
330 <h2>Maengelhaftung</h2>
331 <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>
332
333 <h2>Digitale Inhalte und Dienstleistungen</h2>
334 <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>
335
336 <h2>Streitbeilegung</h2>
337 <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>
338
339 <h2>Datenschutz</h2>
340 <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>
341</div>
342HTML;
343 }
344
345 private function defaultWithdrawalContent(): string {
346 return <<<'HTML'
347<div class="dbx-shop-legal-text">
348 <h1>Widerruf</h1>
349 <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>
350
351 <h2>Widerrufsrecht</h2>
352 <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>
353
354 <h2>Ausuebung des Widerrufs</h2>
355 <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>
356 <p><strong>Widerruf an:</strong><br>
357 [Name/Firma des Shop-Betreibers]<br>
358 [Strasse und Hausnummer]<br>
359 [PLZ Ort]<br>
360 E-Mail: <a href="mailto:[E-Mail-Adresse]">[E-Mail-Adresse]</a></p>
361
362 <h2>Folgen des Widerrufs</h2>
363 <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>
364 <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>
365
366 <h2>Ruecksendekosten und Wertersatz</h2>
367 <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>
368
369 <h2>Ausschluss oder Erloeschen des Widerrufsrechts</h2>
370 <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>
371
372 <h2>Muster-Widerrufsformular</h2>
373 <p>Wenn Sie den Vertrag widerrufen wollen, koennen Sie diesen Text verwenden und an den Shop-Betreiber senden:</p>
374 <div class="dbx-shop-withdrawal-form">
375 <p>An [Name/Firma, Anschrift und E-Mail-Adresse des Shop-Betreibers]</p>
376 <p>Hiermit widerrufe ich den von mir abgeschlossenen Vertrag ueber den Kauf der folgenden Waren oder die Erbringung der folgenden Dienstleistung:</p>
377 <p>[Artikel/Dienstleistung eintragen]</p>
378 <p>Bestellt am: [Datum]<br>Erhalten am: [Datum]</p>
379 <p>Name des Kunden: [Name]<br>Anschrift des Kunden: [Anschrift]</p>
380 <p>Datum: [Datum]</p>
381 <p>Unterschrift des Kunden: [nur bei Mitteilung auf Papier]</p>
382 </div>
383</div>
384HTML;
385 }
386
387 private function mediaUrl(string $path): string {
388 $path = trim(str_replace('\\', '/', $path));
389 if ($path === '') {
390 return '';
391 }
392 if (preg_match('~^https?://~i', $path) || substr($path, 0, 1) === '/') {
393 return $path;
394 }
395 return dbx()->get_base_url() . ltrim($path, '/');
396 }
397
398 private function mediaItemUrl(array $image, bool $thumb = false): string {
399 $mediaId = (int)($image['media_id'] ?? 0);
400 if ($mediaId > 0) {
401 $url = 'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . $mediaId;
402 if ($thumb) {
403 $url .= '&dbx_thumb=1';
404 }
405 return $url;
406 }
407 return $this->mediaUrl((string)($image['image_path'] ?? ''));
408 }
409
410 private function productImage(array $product): array {
411 $images = $product['images'] ?? array();
412 if (is_array($images) && isset($images[0]) && is_array($images[0])) {
413 return $images[0];
414 }
415 return array(
416 'image_path' => 'files/shop/img/software-dashboard.svg',
417 'title' => $product['title'] ?? 'Artikel',
418 'alt' => $product['title'] ?? 'Artikel',
419 );
420 }
421
422 private function primaryGroup(array $product): array {
423 return is_array($product['groups'] ?? null) ? (($product['groups'] ?? array())[0] ?? array()) : array();
424 }
425
426 private function templateName(string $value, string $fallback, string $prefix = ''): string {
427 $value = preg_replace('~[^a-z0-9_-]+~i', '', trim($value));
428 if ($value === '') {
429 return $fallback;
430 }
431 if ($prefix !== '' && strpos($value, $prefix) !== 0) {
432 return $fallback;
433 }
434 return $value;
435 }
436
437 private function shopTemplateExists(string $template): bool {
438 $template = preg_replace('~[^a-z0-9_-]+~i', '', $template);
439 if ($template === '') return false;
440 return is_file(dirname(__DIR__) . '/tpl/htm/' . $template . '.htm');
441 }
442
443 private function mediaTemplateExists(string $template): bool {
444 $template = preg_replace('~[^a-z0-9_-]+~i', '', strtolower($template));
445 if ($template === '') return false;
446 return is_file(dirname(dirname(__DIR__)) . '/dbxContent/tpl/htm/media-' . $template . '.htm');
447 }
448
449 private function groupSetting(array $product, string $key, $fallback) {
450 $group = $this->primaryGroup($product);
451 $value = $group[$key] ?? $fallback;
452 return $value === '' || $value === null ? $fallback : $value;
453 }
454
455 private function productVisual(array $product, string $class = ''): string {
456 $image = $this->productImage($product);
457 $src = $this->mediaItemUrl($image, true);
458 $alt = (string)($image['alt'] ?? $image['title'] ?? $product['title'] ?? '');
459 $count = count($product['images'] ?? array());
460 $html = '<div class="dbx-shop-product-visual ' . $this->h($class) . '">';
461 $html .= '<img class="dbx-shop-product-img" src="' . $this->h($src) . '" alt="' . $this->h($alt) . '" loading="lazy">';
462 $html .= '<span class="dbx-shop-badge">' . $this->h($product['badge'] ?? 'Artikel') . '</span>';
463 if ($count > 1) {
464 $html .= '<span class="dbx-shop-image-count"><i class="bi bi-images"></i> ' . (int)$count . '</span>';
465 }
466 $html .= '</div>';
467 return $html;
468 }
469
470 private function productGallery(array $product): string {
471 $images = $product['images'] ?? array();
472 if (!is_array($images) || $images === array()) {
473 return $this->productVisual($product, 'dbx-shop-product-visual-large');
474 }
475 $overflow = preg_replace('~[^a-z0-9_-]~i', '', (string)$this->groupSetting($product, 'gallery_overflow', 'grid')) ?: 'grid';
476 $click = preg_replace('~[^a-z0-9_-]~i', '', (string)$this->groupSetting($product, 'gallery_click', 'lightbox')) ?: 'lightbox';
477 $visible = max(1, (int)$this->groupSetting($product, 'gallery_visible_count', 3));
478 $imgSize = preg_replace('~[^a-z0-9_-]~i', '', (string)$this->groupSetting($product, 'gallery_image_size', 'original')) ?: 'original';
479 $lightboxWidth = preg_replace('~[^a-z0-9%._-]+~i', '', (string)$this->groupSetting($product, 'gallery_lightbox_width', '100vw')) ?: '100vw';
480 $template = preg_replace('~[^a-z0-9_-]+~i', '', strtolower((string)$this->groupSetting($product, 'gallery_template', 'image-gallery'))) ?: 'image-gallery';
481 if (!$this->mediaTemplateExists($template)) {
482 $template = 'image-gallery';
483 }
484 $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) . '">';
485 foreach ($images as $image) {
486 $url = $this->mediaItemUrl($image, false);
487 $thumbUrl = $this->mediaItemUrl($image, true);
488 if ($url === '') {
489 continue;
490 }
491 $title = (string)($image['title'] ?? $product['title'] ?? '');
492 $alt = (string)($image['alt'] ?? $title);
493 $caption = $title;
494 $html .= $this->tpl()->get_tpl('dbxContent|media-' . $template, array(
495 'id' => (string)($image['media_id'] ?? ''),
496 'url' => $this->h($url),
497 'thumb_url' => $this->h($thumbUrl),
498 'poster_url' => $this->h($thumbUrl),
499 'media_type' => 'image',
500 'title' => $this->h($title),
501 'alt' => $this->h($alt),
502 'caption' => $this->h($caption),
503 'slot' => 'gallery',
504 'mime' => '',
505 ));
506 }
507 $html .= '</div>';
508 return $html;
509 }
510
511 private function placeholder(string $headline, string $text, array $items = array()): string {
512 $list = '';
513 foreach ($items as $item) {
514 $list .= '<li>' . $this->h($item) . '</li>';
515 }
516
517 return $this->tpl()->get_tpl('dbxShop|placeholder', array(
518 'headline' => $this->h($headline),
519 'text' => $this->h($text),
520 'items' => $list !== '' ? '<ul>' . $list . '</ul>' : '',
521 ));
522 }
523
524 private function ensureSeed(): void {
525 $repo = $this->repo();
526 $repo->install();
527 if (count($repo->products(false)) === 0 || count($repo->shippingGroups()) === 0 || count($repo->channelGroups()) === 0 || count($repo->allImages()) === 0) {
528 $repo->seedDemoProducts();
529 }
530 }
531
532 private function activeChannel(): string {
533 return 'shop';
534 }
535
536 private function channelNav(string $active): string {
537 $channels = $this->repo()->channels();
538 $html = '<div class="dbx-shop-channel-nav">';
539 foreach ($channels as $channel) {
540 $key = (string)($channel['channel_key'] ?? '');
541 if ($key === '') {
542 continue;
543 }
544 $cls = $key === $active ? ' active' : '';
545 $html .= '<a class="btn btn-outline-secondary btn-sm' . $cls . '" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog&amp;channel=' . rawurlencode($key) . '">';
546 $html .= $this->h($channel['title'] ?? $key);
547 $html .= '</a>';
548 }
549 $html .= '</div>';
550 return $html;
551 }
552
553 private function productHasChannel(array $product, string $channel): bool {
554 foreach (($product['channels'] ?? array()) as $ch) {
555 if ((string)($ch['channel_key'] ?? '') === $channel && (int)($ch['active'] ?? 0) === 1) {
556 return true;
557 }
558 }
559 return false;
560 }
561
562 private function groupsHtml(array $product): string {
563 $html = '';
564 foreach (($product['groups'] ?? array()) as $group) {
565 $groupId = (int)($group['id'] ?? 0);
566 $href = $groupId > 0 ? '?dbx_modul=dbxShop&amp;dbx_run1=catalog&amp;group=' . $groupId : '';
567 $label = $this->h($group['title'] ?? '');
568 $html .= $href !== ''
569 ? '<a class="dbx-shop-chip" href="' . $href . '">' . $label . '</a>'
570 : '<span class="dbx-shop-chip">' . $label . '</span>';
571 }
572 return $html;
573 }
574
575 private function catalogGroupId(): int {
576 return max(0, (int)dbx()->get_modul_var('group', 0, 'int'));
577 }
578
579 private function groupImageUrl(array $group): string {
580 $image = $this->repo()->primaryImageForGroup((int)($group['id'] ?? 0));
581 if (is_array($image)) {
582 $url = $this->mediaItemUrl($image, true);
583 if ($url !== '') {
584 return $url;
585 }
586 }
587 return $this->mediaUrl('files/shop/img/software-dashboard.svg');
588 }
589
590 private function catalogGroupBreadcrumb(int $groupId): string {
591 if ($groupId <= 0) {
592 return '';
593 }
594 $path = $this->repo()->groupPath($groupId);
595 if ($path === array()) {
596 return '';
597 }
598 $html = '<nav class="dbx-shop-group-breadcrumb" aria-label="Artikelgruppen">';
599 $html .= '<a href="?dbx_modul=dbxShop&amp;dbx_run1=catalog">Alle Artikel</a>';
600 foreach ($path as $group) {
601 $id = (int)($group['id'] ?? 0);
602 $title = $this->h($group['title'] ?? '');
603 if ($id === $groupId) {
604 $html .= '<span>' . $title . '</span>';
605 } else {
606 $html .= '<a href="?dbx_modul=dbxShop&amp;dbx_run1=catalog&amp;group=' . $id . '">' . $title . '</a>';
607 }
608 }
609 $html .= '</nav>';
610 return $html;
611 }
612
613 private function catalogGroupNavigation(int $parentId): string {
614 $groups = $this->repo()->groupsByParent($parentId, true);
615 if ($groups === array()) {
616 return '';
617 }
618 $html = '<section class="dbx-shop-group-grid" aria-label="Artikelgruppen">';
619 foreach ($groups as $group) {
620 $id = (int)($group['id'] ?? 0);
621 if ($id <= 0) continue;
622 $title = trim((string)($group['title'] ?? 'Artikelgruppe'));
623 $description = trim((string)($group['description'] ?? ''));
624 $html .= '<a class="dbx-shop-group-card" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog&amp;group=' . $id . '">';
625 $html .= '<span class="dbx-shop-group-card-image"><img src="' . $this->h($this->groupImageUrl($group)) . '" alt="' . $this->h($title) . '" loading="lazy"></span>';
626 $html .= '<span class="dbx-shop-group-card-body"><strong>' . $this->h($title) . '</strong>';
627 if ($description !== '') {
628 $html .= '<small>' . $this->h($description) . '</small>';
629 }
630 $html .= '</span></a>';
631 }
632 $html .= '</section>';
633 return $html;
634 }
635
636 private function productInCatalogGroup(array $product, int $groupId): bool {
637 if ($groupId <= 0) {
638 return true;
639 }
640 if ((int)($product['product_group_id'] ?? 0) === $groupId) {
641 return true;
642 }
643 foreach (($product['groups'] ?? array()) as $group) {
644 if ((int)($group['id'] ?? 0) === $groupId) {
645 return true;
646 }
647 }
648 return false;
649 }
650
651 private function channelsHtml(array $product): string {
652 $html = '';
653 foreach (($product['channels'] ?? array()) as $channel) {
654 if ((int)($channel['active'] ?? 0) !== 1) {
655 continue;
656 }
657 $html .= '<span class="dbx-shop-chip dbx-shop-chip-channel">' . $this->h($channel['title'] ?? $channel['channel_key'] ?? '') . '</span>';
658 }
659 return $html;
660 }
661
662 private function normalizedText(string $value): string {
663 $value = strtolower($value);
664 $value = strtr($value, array('ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss'));
665 $value = preg_replace('~[^a-z0-9]+~', ' ', $value) ?: '';
666 return preg_replace('~\\s+~', ' ', trim($value)) ?: '';
667 }
668
669 private function attributeText(array $product): string {
670 $parts = array();
671 foreach (($product['attributes'] ?? array()) as $attribute) {
672 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
673 $parts[] = (string)($attribute['title'] ?? '');
674 $parts[] = (string)($attribute['attr_key'] ?? '');
675 if ($value !== '') {
676 $parts[] = $value;
677 }
678 }
679 return implode(' ', $parts);
680 }
681
682 private function groupText(array $product): string {
683 $parts = array();
684 foreach (($product['groups'] ?? array()) as $group) {
685 $parts[] = (string)($group['title'] ?? '');
686 $parts[] = (string)($group['group_key'] ?? '');
687 $parts[] = (string)($group['description'] ?? '');
688 $parts[] = (string)($group['attribute_notes'] ?? '');
689 }
690 return implode(' ', $parts);
691 }
692
693 private function searchTerms(string $query): array {
694 $terms = preg_split('~\\s+~', $this->normalizedText($query)) ?: array();
695 $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'));
696 $out = array();
697 foreach ($terms as $term) {
698 $term = trim($term);
699 if ($term === '' || isset($stopWords[$term])) {
700 continue;
701 }
702 if (strlen($term) < 2 && !ctype_digit($term)) {
703 continue;
704 }
705 $out[$term] = true;
706 }
707 return array_keys($out);
708 }
709
710 private function textMatchesSearchTerm(string $text, string $term): bool {
711 return $this->searchFieldScore($text, $term, 1) > 0;
712 }
713
714 private function searchFieldScore(string $text, string $term, int $weight): int {
715 if ($text === '' || $term === '') {
716 return 0;
717 }
718 if ($text === $term) {
719 return $weight * 8;
720 }
721 $termLength = strlen($term);
722 $compactText = str_replace(' ', '', $text);
723 $compactTerm = str_replace(' ', '', $term);
724 if (strpos($text, $term) !== false || strpos($compactText, $compactTerm) !== false) {
725 return $weight * 5;
726 }
727 $best = 0;
728 foreach (preg_split('~\\s+~', $text) ?: array() as $token) {
729 $token = trim($token);
730 if ($token === '') {
731 continue;
732 }
733 if ($token === $term) {
734 $best = max($best, $weight * 6);
735 continue;
736 }
737 if ($termLength < 3) {
738 continue;
739 }
740 if (strlen($token) >= $termLength && strpos($token, $term) === 0) {
741 $best = max($best, $weight * 4);
742 continue;
743 }
744 if (
745 $termLength >= 4
746 && strlen($token) >= 4
747 && substr($token, 0, 3) === substr($term, 0, 3)
748 && abs(strlen($token) - $termLength) <= ($termLength >= 7 ? 2 : 1)
749 && levenshtein($token, $term) <= ($termLength >= 7 ? 2 : 1)
750 ) {
751 $best = max($best, $weight * 2);
752 }
753 }
754 return $best;
755 }
756
757 private function productSearchScore(array $product, string $query): int {
758 $terms = $this->searchTerms($query);
759 if ($terms === array()) {
760 return 1;
761 }
762
763 $primary = $this->normalizedText(implode(' ', array(
764 (string)($product['sku'] ?? ''),
765 (string)($product['title'] ?? ''),
766 (string)($product['category'] ?? ''),
767 (string)($product['badge'] ?? ''),
768 (string)($product['product_type'] ?? ''),
769 )));
770 $secondary = $this->normalizedText(implode(' ', array(
771 (string)($product['summary'] ?? ''),
772 (string)($product['description'] ?? ''),
773 )));
774 $attributes = $this->normalizedText($this->attributeText($product));
775 $groups = $this->normalizedText($this->groupText($product));
776
777 $score = 0;
778 $matched = 0;
779 $firstTermPrimaryScore = 0;
780 $termCount = count($terms);
781
782 foreach ($terms as $idx => $term) {
783 $primaryScore = $this->searchFieldScore($primary, $term, 10);
784 $termScore = max(
785 $primaryScore,
786 $this->searchFieldScore($attributes, $term, 7),
787 $this->searchFieldScore($secondary, $term, 4),
788 $this->searchFieldScore($groups, $term, 3)
789 );
790
791 if ($idx === 0) {
792 $firstTermPrimaryScore = $primaryScore;
793 }
794 if ($termScore > 0) {
795 $matched++;
796 $score += $termScore;
797 }
798 }
799
800 if ($matched === 0) {
801 return 0;
802 }
803 if ($termCount === 1) {
804 return $score;
805 }
806
807 if ($matched === $termCount || $firstTermPrimaryScore > 0 || $score >= 20) {
808 return $score + ($matched * 3);
809 }
810
811 return 0;
812 }
813
814 private function attributesInlineHtml(array $product, int $max = 4): string {
815 $html = '';
816 $count = 0;
817 foreach (($product['attributes'] ?? array()) as $attribute) {
818 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
819 if ($value === '') continue;
820 $html .= '<span class="dbx-shop-attribute-chip"><span>' . $this->h($attribute['title'] ?? '') . '</span><strong>' . $this->h($value) . '</strong></span>';
821 $count++;
822 if ($count >= $max) break;
823 }
824 return $html !== '' ? '<div class="dbx-shop-attribute-row">' . $html . '</div>' : '';
825 }
826
827 private function attributesTableHtml(array $product): string {
828 $rows = '';
829 foreach (($product['attributes'] ?? array()) as $attribute) {
830 $value = trim((string)($attribute['display_value'] ?? $attribute['value_text'] ?? ''));
831 if ($value === '') continue;
832 $rows .= '<tr><th>' . $this->h($attribute['title'] ?? '') . '</th><td>' . $this->h($value) . '</td></tr>';
833 }
834 if ($rows === '') {
835 return '';
836 }
837 return '<div class="dbx-shop-attributes"><h4>Eigenschaften</h4><table><tbody>' . $rows . '</tbody></table></div>';
838 }
839
840 private function selectedAttributeFilters(): array {
841 $raw = $_GET['attr'] ?? array();
842 if (!is_array($raw)) {
843 return array();
844 }
845 $out = array();
846 foreach ($raw as $id => $value) {
847 $id = (int)$id;
848 $value = trim((string)$value);
849 if ($id > 0 && $value !== '') {
850 $out[$id] = $value;
851 }
852 }
853 return $out;
854 }
855
856 private function productMatchesQuery(array $product, string $query): bool {
857 return $this->productSearchScore($product, $query) > 0;
858 }
859
860 private function productMatchesAttributeFilters(array $product, array $filters): bool {
861 if ($filters === array()) {
862 return true;
863 }
864 $values = array();
865 foreach (($product['attributes'] ?? array()) as $attribute) {
866 $id = (int)($attribute['id'] ?? 0);
867 if ($id <= 0) continue;
868 $values[$id] = $this->normalizedText((string)($attribute['value_text'] ?? ''));
869 }
870 foreach ($filters as $id => $value) {
871 if (!isset($values[$id]) || $values[$id] !== $this->normalizedText((string)$value)) {
872 return false;
873 }
874 }
875 return true;
876 }
877
878 private function catalogFiltersHtml(string $channel, string $query, array $selected, int $groupId = 0): string {
879 $searchField = dbx()->search_html(dbx()->search_defaults(array(
880 'name' => 'q',
881 'value' => $query,
882 'placeholder' => 'Suchbegriff eingeben',
883 'title' => 'Suche',
884 'input_class' => 'form-control-sm dbx-shop-search-input',
885 'wrap_class' => 'dbx-shop-search-wrap',
886 'data_role' => 'shop-search',
887 )));
888
889 $filterFields = '';
890 foreach ($this->repo()->attributeFilterDefinitions() as $definition) {
891 $id = (int)($definition['id'] ?? 0);
892 $values = $definition['values'] ?? array();
893 if ($id <= 0 || !is_array($values) || $values === array()) continue;
894 $label = trim((string)($definition['title'] ?? ''));
895 $group = trim((string)($definition['group_title'] ?? ''));
896 $filterFields .= '<label><span>' . $this->h($group !== '' ? $group . ': ' . $label : $label) . '</span><select class="form-select form-select-sm" name="attr[' . $id . ']">';
897 $filterFields .= '<option value="">Alle</option>';
898 foreach ($values as $value) {
899 $sel = isset($selected[$id]) && $this->normalizedText((string)$selected[$id]) === $this->normalizedText((string)$value) ? ' selected' : '';
900 $filterFields .= '<option value="' . $this->h($value) . '"' . $sel . '>' . $this->h($value) . '</option>';
901 }
902 $filterFields .= '</select></label>';
903 }
904 $advancedFilters = '';
905 if ($filterFields !== '') {
906 $open = $selected !== array() ? ' open' : '';
907 $advancedFilters .= '<details class="dbx-shop-filter-advanced"' . $open . '>';
908 $advancedFilters .= '<summary><i class="bi bi-sliders"></i> Filter verfeinern</summary>';
909 $advancedFilters .= '<div class="dbx-shop-filter-row">' . $filterFields . '</div>';
910 $advancedFilters .= '</details>';
911 }
912
913 $form = dbx()->get_system_obj('dbxForm');
914 $form->init('shop-catalog-filter-form', 'shop-catalog-filter-form');
915 $form->set_editor_class_file(__FILE__);
916 $form->_action = '?dbx_modul=dbxShop&dbx_run1=catalog';
917 $form->_data = array('q' => $query);
918 $form->_msg_info = '';
919 $form->_msg_success = '';
920 $form->_msg_error = '';
921 $form->_msg_warning = '';
922 $form->add_rep('frame_skip_form_wrap', '1');
923 $form->add_rep('frame_form_open', '<form action="?dbx_modul=dbxShop&amp;dbx_run1=catalog" method="get" class="dbx-shop-filter">');
924 $form->add_rep('frame_form_close', '</form>');
925 $form->add_rep('search_field', $searchField);
926 $form->add_rep('advanced_filters', $advancedFilters);
927 $form->add_rep('group_hidden', $groupId > 0 ? '<input type="hidden" name="group" value="' . $groupId . '">' : '');
928 return $form->run();
929 }
930
931 private function productTemplateData(array $product, string $channel): array {
932 $sku = (string)($product['sku'] ?? '');
933 return array(
934 'sku' => $this->h($sku),
935 'title' => $this->h($product['title'] ?? ''),
936 'summary' => $this->h($product['summary'] ?? ''),
937 'description' => $this->h($product['description'] ?? $product['summary'] ?? ''),
938 'groups' => $this->groupsHtml($product),
939 'channels' => '',
940 'attributes' => $this->attributesInlineHtml($product, 4),
941 'attributes_table' => $this->attributesTableHtml($product),
942 'gallery' => $this->productGallery($product),
943 'visual' => $this->productVisual($product),
944 'price' => $this->money($product['price_gross'] ?? 0),
945 'tax_shipping' => $this->taxShippingHtml($product),
946 'shipping_info' => $this->shippingInfoHtml($product),
947 'stock_info' => $this->stockInfoHtml($product),
948 'buy_form' => $this->buyFormHtml($product),
949 'detail_url' => '?dbx_modul=dbxShop&amp;dbx_run1=product&amp;sku=' . rawurlencode($sku),
950 'catalog_url' => '?dbx_modul=dbxShop&amp;dbx_run1=catalog',
951 'cart_url' => '?dbx_modul=dbxShop&amp;dbx_run1=cart&amp;sku=' . rawurlencode($sku),
952 'card_class' => $this->h($this->cssTemplateClass((string)$this->groupSetting($product, 'card_template', 'product-card-default'))),
953 'detail_class' => $this->h($this->cssTemplateClass((string)$this->groupSetting($product, 'detail_template', 'product-detail-default'))),
954 );
955 }
956
957 private function cssTemplateClass(string $template): string {
958 $template = preg_replace('~[^a-z0-9_-]+~i', '-', trim($template));
959 return $template !== '' ? 'is-template-' . strtolower($template) : '';
960 }
961
962 private function renderProductCard(array $product, string $channel): string {
963 $template = $this->templateName((string)$this->groupSetting($product, 'card_template', 'product-card-default'), 'product-card-default', 'product-card-');
964 if (!$this->shopTemplateExists($template)) {
965 $template = 'product-card-default';
966 }
967 return $this->tpl()->get_tpl('dbxShop|' . $template, $this->productTemplateData($product, $channel));
968 }
969
970 private function catalogReportHtml(array $products, string $channel, string $query, array $attributeFilters, int $groupId): string {
971 $report = dbx()->get_system_obj('dbxReport');
972 $report->init('shop-catalog-report', 'dbxShop|shop-catalog-report');
973 $report->set_editor_class_file(__FILE__);
974 $report->_mode = 'tpl';
975 $report->_pages = true;
976 $report->_create_row_select = false;
977 $report->_create_row_edit = false;
978 $report->_create_row_delete = false;
979 $report->_but_pagination = 7;
980 $rowsPerPage = max(6, min(48, (int)$report->get_fld_val('dbx_rrows', 12, 'int')));
981 $position = max(0, (int)$report->get_fld_val('dbx_rpos', 0, 'int'));
982 $filteredCount = count($products);
983 if ($position >= $filteredCount && $filteredCount > 0) {
984 $position = max(0, (int)(floor(($filteredCount - 1) / $rowsPerPage) * $rowsPerPage));
985 }
986 $visible = array_slice($products, $position, $rowsPerPage);
987 $rows = array();
988 foreach ($visible as $product) {
989 $rows[] = array(
990 'id' => (int)($product['id'] ?? 0),
991 'card' => $this->renderProductCard($product, $channel),
992 );
993 }
994
995 $queryParts = array(
996 'dbx_modul' => 'dbxShop',
997 'dbx_run1' => 'catalog',
998 );
999 if ($query !== '') {
1000 $queryParts['q'] = $query;
1001 }
1002 if ($groupId > 0) {
1003 $queryParts['group'] = $groupId;
1004 }
1005 foreach ($attributeFilters as $id => $value) {
1006 $queryParts['attr[' . (int)$id . ']'] = (string)$value;
1007 }
1008 $report->_action = '?' . http_build_query($queryParts, '', '&');
1009 $report->_rflds = array('card' => 'Artikel');
1010 $report->_rpt_format = array('card' => 'html');
1011 $report->_rrows = $rowsPerPage;
1012 $report->_rpos = $position;
1013 $report->_count_all = $filteredCount;
1014 $report->_rcount = $filteredCount;
1015 $report->_rdata = $rows;
1016 return $report->run();
1017 }
1018
1019 private function renderProductDetail(array $product, string $channel): string {
1020 $template = $this->templateName((string)$this->groupSetting($product, 'detail_template', 'product-detail-default'), 'product-detail-default', 'product-detail-');
1021 if (!$this->shopTemplateExists($template)) {
1022 $template = 'product-detail-default';
1023 }
1024 $data = $this->productTemplateData($product, $channel);
1025 $data['attributes'] = $data['attributes_table'];
1026 return $this->tpl()->get_tpl('dbxShop|' . $template, $data);
1027 }
1028
1029 private function taxShippingHtml(array $product): string {
1030 $tax = $this->h(number_format((float)($product['effective_tax_rate'] ?? 0), 2, ',', '.'));
1031 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
1032 $shippingText = $shipping > 0 ? $this->money($shipping) . ' Versand' : 'versandfrei';
1033 $showTax = $this->settingsBool($this->shopConfig(), 'tax_display_enabled', true);
1034 $parts = array();
1035 if ($showTax) {
1036 $parts[] = $tax . '% MwSt.';
1037 }
1038 $parts[] = $this->h($shippingText);
1039 return '<small>' . implode(', ', $parts) . '</small>';
1040 }
1041
1042 private function shippingInfoHtml(array $product): string {
1043 $deliveryTime = trim((string)($product['effective_delivery_time'] ?? ''));
1044 $shippingWay = trim((string)($product['effective_shipping_way'] ?? ''));
1045 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
1046 $shippingText = $shipping > 0 ? $this->money($shipping) : 'versandfrei';
1047 $rows = '';
1048
1049 if ($deliveryTime !== '') {
1050 $rows .= '<div class="dbx-shop-shipping-info-row"><i class="bi bi-clock"></i><span>Lieferzeit: ' . $this->h($deliveryTime) . '</span></div>';
1051 }
1052 if ($shippingWay !== '') {
1053 $rows .= '<div class="dbx-shop-shipping-info-row"><i class="bi bi-truck"></i><span>Versandweg: ' . $this->h($shippingWay) . '</span></div>';
1054 }
1055 $rows .= '<div class="dbx-shop-shipping-info-row"><i class="bi bi-box-seam"></i><span>Versandkosten: ' . $this->h($shippingText) . '</span></div>';
1056
1057 return '<div class="dbx-shop-shipping-info">' . $rows . '</div>';
1058 }
1059
1060 private function stockInfoHtml(array $product): string {
1061 $cfg = $this->shopConfig();
1062 if (!$this->settingsBool($cfg, 'stock_enabled', false) || (string)($product['product_type'] ?? '') !== 'physical') {
1063 return '';
1064 }
1065 $stock = (int)($product['stock'] ?? 0);
1066 if ($stock <= 0) {
1067 return '<div class="alert alert-warning py-2 mb-2"><i class="bi bi-exclamation-triangle"></i> Aktuell nicht auf Lager.</div>';
1068 }
1069 if ($stock <= 3) {
1070 return '<div class="alert alert-info py-2 mb-2"><i class="bi bi-box-seam"></i> Nur noch ' . $stock . ' verfuegbar.</div>';
1071 }
1072 return '<div class="dbx-shop-shipping-info-row"><i class="bi bi-box-seam"></i><span>Lagerbestand: verfuegbar</span></div>';
1073 }
1074
1075 private function buyFormHtml(array $product): string {
1076 $sku = (string)($product['sku'] ?? '');
1077 if ($sku === '') {
1078 return '';
1079 }
1080 $cfg = $this->shopConfig();
1081 if ($this->settingsBool($cfg, 'stock_enabled', false) && (string)($product['product_type'] ?? '') === 'physical' && (int)($product['stock'] ?? 0) <= 0) {
1082 return '<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog"><i class="bi bi-arrow-left"></i> Zurueck zum Katalog</a>';
1083 }
1084 $form = dbx()->get_system_obj('dbxForm');
1085 $form->init('shop-buy-' . preg_replace('~[^a-z0-9_-]+~i', '-', strtolower($sku)), 'shop-buy-form');
1086 $form->set_editor_class_file(__FILE__);
1087 $form->_action = '?dbx_modul=dbxShop&dbx_run1=cart&sku=' . rawurlencode($sku);
1088 $form->_data = array('qty' => 1);
1089 $form->_msg_info = '';
1090 $form->_msg_success = '';
1091 $form->_msg_error = '';
1092 $form->_msg_warning = '';
1093 $form->add_rep('frame_skip_form_wrap', '1');
1094 $form->add_rep('frame_form_open', '<form action="' . $this->h($form->_action) . '" method="post" id="dbx_shop_buy_' . $this->h($sku) . '" class="dbx-shop-buy-form">');
1095 $form->add_rep('frame_form_close', '</form>');
1096 $form->add_rep('catalog_url', '?dbx_modul=dbxShop&amp;dbx_run1=catalog');
1097 $form->add_fld('qty', 'dbxShop|shop-field-qty', label: 'Anzahl', rules: 'int|min=1');
1098 return $form->run();
1099 }
1100
1101 private function startSession(): void {
1102 if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
1103 session_start();
1104 }
1105 if (!isset($_SESSION['dbxShop_cart']) || !is_array($_SESSION['dbxShop_cart'])) {
1106 $_SESSION['dbxShop_cart'] = array();
1107 }
1108 }
1109
1110 private function cartItems(): array {
1111 $this->startSession();
1112 return $_SESSION['dbxShop_cart'];
1113 }
1114
1115 private function requestedQuantity($value, int $fallback = 1): int {
1116 $qty = (int)$value;
1117 return max(1, min(999, $qty > 0 ? $qty : $fallback));
1118 }
1119
1120 private function addToCart(string $sku, int $qty = 1): void {
1121 if ($sku === '') {
1122 return;
1123 }
1124 $product = $this->repo()->productBySku($sku);
1125 if (!$product) {
1126 return;
1127 }
1128 $this->startSession();
1129 $_SESSION['dbxShop_cart'][$sku] = max(0, (int)($_SESSION['dbxShop_cart'][$sku] ?? 0)) + $this->requestedQuantity($qty);
1130 }
1131
1132 private function updateCartQuantities(array $quantities): void {
1133 $this->startSession();
1134 foreach ($quantities as $sku => $qty) {
1135 $sku = (string)$sku;
1136 if (!isset($_SESSION['dbxShop_cart'][$sku])) {
1137 continue;
1138 }
1139 $_SESSION['dbxShop_cart'][$sku] = $this->requestedQuantity($qty);
1140 }
1141 }
1142
1143 private function removeFromCart(string $sku): void {
1144 $sku = trim($sku);
1145 if ($sku === '') {
1146 return;
1147 }
1148 $this->startSession();
1149 unset($_SESSION['dbxShop_cart'][$sku]);
1150 }
1151
1152 private function addedToCartDialog(array $product): string {
1153 $title = trim((string)($product['title'] ?? 'Artikel'));
1154 $qty = $this->requestedQuantity(dbx()->get_modul_var('qty', '1', 'parameter'));
1155 $body = '<div class="dbx-shop-added-dialog" role="dialog" aria-modal="true" aria-labelledby="dbx-shop-added-title">';
1156 $body .= '<div class="dbx-shop-added-dialog-backdrop"></div>';
1157 $body .= '<div class="dbx-shop-added-dialog-box">';
1158 $body .= '<div class="dbx-shop-added-dialog-icon"><i class="bi bi-check2"></i></div>';
1159 $body .= '<h3 id="dbx-shop-added-title">Artikel wurde in den Warenkorb gelegt</h3>';
1160 $body .= '<p>' . $this->h($title) . ' <span class="dbx-shop-added-qty">x ' . (int)$qty . '</span></p>';
1161 $body .= '<div class="dbx-shop-added-dialog-actions">';
1162 $body .= '<a class="btn btn-outline-primary" href="?dbx_modul=dbxShop&amp;dbx_run1=cart"><i class="bi bi-cart"></i> Warenkorb</a>';
1163 $body .= '<a class="btn btn-primary" href="?dbx_modul=dbxShop&amp;dbx_run1=checkout"><i class="bi bi-credit-card"></i> Kasse</a>';
1164 $body .= '<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog"><i class="bi bi-grid"></i> Weiter einkaufen</a>';
1165 $body .= '</div>';
1166 $body .= '</div>';
1167 $body .= '</div>';
1168 return $body;
1169 }
1170
1171 private function cartRowsAndSum(bool $editable = false): array {
1172 $rows = '';
1173 $sum = 0.0;
1174 foreach ($this->cartItems() as $sku => $qty) {
1175 $product = $this->repo()->productBySku((string)$sku);
1176 if (!$product) {
1177 continue;
1178 }
1179 $qty = max(1, (int)$qty);
1180 $price = (float)($product['price_gross'] ?? 0);
1181 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
1182 $line = ($price + $shipping) * $qty;
1183 $sum += $line;
1184 $qtyHtml = $editable
1185 ? '<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 . '">'
1186 : (string)(int)$qty;
1187 $rows .= '<tr>';
1188 $rows .= '<td><strong>' . $this->h($product['title'] ?? '') . '</strong><br><small>' . $this->h($sku) . '</small></td>';
1189 $rows .= '<td class="text-end">' . $qtyHtml . '</td>';
1190 $rows .= '<td class="text-end">' . $this->money($price) . '</td>';
1191 $rows .= '<td class="text-end">' . $this->money($shipping) . '</td>';
1192 $rows .= '<td class="text-end">' . $this->money($line) . '</td>';
1193 $rows .= '</tr>';
1194 }
1195 return array($rows, $sum);
1196 }
1197
1198 private function cartReportDataAndSum(): array {
1199 $rows = array();
1200 $sum = 0.0;
1201 foreach ($this->cartItems() as $sku => $qty) {
1202 $product = $this->repo()->productBySku((string)$sku);
1203 if (!$product) {
1204 continue;
1205 }
1206 $qty = max(1, (int)$qty);
1207 $price = (float)($product['price_gross'] ?? 0);
1208 $shipping = (float)($product['effective_shipping_gross'] ?? 0);
1209 $line = ($price + $shipping) * $qty;
1210 $sum += $line;
1211 $skuText = (string)$sku;
1212 $rows[] = array(
1213 'id' => $skuText,
1214 'remove' => '<a class="btn btn-sm btn-outline-danger dbxAjax dbxConfirm dbx-shop-cart-remove" href="?dbx_modul=dbxShop&amp;dbx_run1=cart&amp;remove=' . rawurlencode($skuText) . '" data-confirm-title="<i class=\'bi bi-trash\'></i> Artikel entfernen" data-confirm="Diesen Artikel aus dem Warenkorb entfernen?" data-confirm-hint="<small>Die Position wird aus dem Warenkorb geloescht.</small>" data-confirm-buttons="yesno" title="Artikel entfernen"><i class="bi bi-trash"></i></a>',
1215 'article' => '<strong>' . $this->h($product['title'] ?? '') . '</strong><br><small>' . $this->h($skuText) . '</small>',
1216 '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 . '">',
1217 'price' => '<span class="dbx-shop-money">' . $this->money($price) . '</span>',
1218 'shipping' => '<span class="dbx-shop-money">' . $this->money($shipping) . '</span>',
1219 'line' => '<span class="dbx-shop-money"><strong>' . $this->money($line) . '</strong></span>',
1220 );
1221 }
1222 return array($rows, $sum);
1223 }
1224
1225 private function cartReportHtml(array $rows, float $sum): string {
1226 $report = dbx()->get_system_obj('dbxReport');
1227 $report->init('shop-cart-report', 'dbxShop|shop-cart-report');
1228 $report->set_editor_class_file(__FILE__);
1229 $report->_action = '?dbx_modul=dbxShop&dbx_run1=cart';
1230 $report->_mode = 'table';
1231 $report->_pages = false;
1232 $report->_rdata = $rows;
1233 $report->_rcount = count($rows);
1234 $report->_count_all = count($rows);
1235 $report->_rrows = max(1, count($rows));
1236 $report->_rpos = 0;
1237 $report->_create_row_select = false;
1238 $report->_create_row_edit = false;
1239 $report->_create_row_delete = false;
1240 $report->_rflds = array(
1241 'remove' => 'Aktion',
1242 'article' => 'Artikel',
1243 'qty' => 'Menge',
1244 'price' => 'Preis',
1245 'shipping' => 'Versand',
1246 'line' => 'Summe',
1247 );
1248 $report->_rpt_format = array(
1249 'remove' => 'html',
1250 'article' => 'html',
1251 'qty' => 'html',
1252 'price' => 'html',
1253 'shipping' => 'html',
1254 'line' => 'html',
1255 );
1256 $report->add_rep('cart_sum', $this->money($sum));
1257 return $report->run();
1258 }
1259
1260 private function cartBodyHtml(): string {
1261 [$reportRows, $sum] = $this->cartReportDataAndSum();
1262
1263 if ($reportRows === array()) {
1264 return $this->placeholder('Der Warenkorb ist leer', 'Waehlen Sie im Katalog einen Artikel und legen Sie ihn in den Warenkorb.');
1265 }
1266
1267 return $this->cartReportHtml($reportRows, $sum);
1268 }
1269
1270 private function absoluteShopUrl(string $run, array $params = array()): string {
1271 $query = array_merge(array(
1272 'dbx_modul' => 'dbxShop',
1273 'dbx_run1' => $run,
1274 ), $params);
1275 return dbx()->get_base_url() . '?' . http_build_query($query, '', '&');
1276 }
1277
1278 private function checkoutPaymentOptions(): array {
1279 $cfg = $this->shopConfig();
1280 $options = array();
1281 if ($this->settingsBool($cfg, 'payment_bank_transfer_enabled', true)) {
1282 $options['bank_transfer'] = 'Vorkasse / Ueberweisung';
1283 }
1284 if ($this->settingsBool($cfg, 'payment_invoice_enabled', false)) {
1285 $options['invoice'] = 'Rechnung';
1286 }
1287 if ($this->settingsBool($cfg, 'payment_paypal_enabled', false) && $this->paypal()->isConfigured()) {
1288 $options['paypal'] = 'PayPal';
1289 }
1290 if ($this->settingsBool($cfg, 'payment_amazon_pay_enabled', false) && $this->amazonPay()->isConfigured()) {
1291 $options['amazon_pay'] = 'Amazon Pay';
1292 }
1293 return $options;
1294 }
1295
1296 private function paymentMethodLabels(): array {
1297 return array(
1298 'bank_transfer' => 'Vorkasse / Ueberweisung',
1299 'invoice' => 'Rechnung',
1300 'paypal' => 'PayPal',
1301 'amazon_pay' => 'Amazon Pay',
1302 );
1303 }
1304
1305 private function paymentProviderLabel(string $provider): string {
1306 $labels = $this->paymentMethodLabels();
1307 $channelLabels = array(
1308 'shop' => 'Shop',
1309 'amazon' => 'Amazon Channel',
1310 'ebay' => 'eBay Channel',
1311 'kleinanzeigen' => 'Kleinanzeigen Channel',
1312 'mobile' => 'mobile.de Channel',
1313 );
1314 return $labels[$provider] ?? $channelLabels[$provider] ?? $provider;
1315 }
1316
1317 private function paymentInstructions(string $method, array $order = array()): string {
1318 $cfg = $this->shopConfig();
1319 if ($method === 'bank_transfer') {
1320 $lines = array();
1321 $intro = trim((string)($cfg['payment_bank_transfer_instructions'] ?? ''));
1322 if ($intro === '') {
1323 $intro = 'Bitte ueberweisen Sie den Rechnungsbetrag unter Angabe der Bestellnummer.';
1324 }
1325 $lines[] = $intro;
1326 foreach (array(
1327 'Kontoinhaber' => 'payment_bank_transfer_account_owner',
1328 'IBAN' => 'payment_bank_transfer_iban',
1329 'BIC' => 'payment_bank_transfer_bic',
1330 'Bank' => 'payment_bank_transfer_bank_name',
1331 ) as $label => $key) {
1332 $value = trim((string)($cfg[$key] ?? ''));
1333 if ($value !== '') {
1334 $lines[] = $label . ': ' . $value;
1335 }
1336 }
1337 if (trim((string)($order['order_no'] ?? '')) !== '') {
1338 $lines[] = 'Verwendungszweck: ' . (string)$order['order_no'];
1339 }
1340 return implode("\n", $lines);
1341 }
1342 if ($method === 'invoice') {
1343 $text = trim((string)($cfg['payment_invoice_instructions'] ?? ''));
1344 return $text !== '' ? $text : 'Sie erhalten eine Rechnung. Bitte zahlen Sie innerhalb der angegebenen Frist.';
1345 }
1346 if ($method === 'amazon_pay') {
1347 return 'Die Zahlung wird ueber Amazon Pay autorisiert und nach erfolgreicher Rueckmeldung automatisch aktualisiert.';
1348 }
1349 if ($method === 'paypal') {
1350 return 'Die Zahlung wird ueber PayPal autorisiert und nach erfolgreicher Rueckmeldung automatisch aktualisiert.';
1351 }
1352 return '';
1353 }
1354
1355 private function checkoutPaymentHelp(array $options): string {
1356 if ($options === array()) {
1357 return '<div class="alert alert-warning mb-0">Aktuell ist keine Zahlungsart aktiv oder vollstaendig konfiguriert.</div>';
1358 }
1359 $parts = array();
1360 if (isset($options['bank_transfer'])) {
1361 $parts[] = '<div><strong>Vorkasse / Ueberweisung</strong><span>Die Bestellung wird gespeichert. Der Kunde ueberweist danach mit Bestellnummer als Verwendungszweck.</span></div>';
1362 }
1363 if (isset($options['invoice'])) {
1364 $parts[] = '<div><strong>Rechnung</strong><span>Die Bestellung wird gespeichert und spaeter per Rechnung bezahlt.</span></div>';
1365 }
1366 if (isset($options['paypal'])) {
1367 $parts[] = '<div><strong>PayPal</strong><span>Sie werden zu PayPal weitergeleitet. Nach der Freigabe wird die Zahlung automatisch bestaetigt.</span></div>';
1368 }
1369 if (isset($options['amazon_pay'])) {
1370 $parts[] = '<div><strong>Amazon Pay</strong><span>Sie werden zu Amazon Pay weitergeleitet. Sandbox und Live laufen ueber die in den Shop-Einstellungen gespeicherten Zugangsdaten.</span></div>';
1371 }
1372 return '<div class="dbx-shop-payment-method-help">' . implode('', $parts) . '</div>';
1373 }
1374
1375 private function checkoutTableHtml(string $rows, float $sum): string {
1376 return '<div class="dbx-shop-cart table-responsive">'
1377 . '<table class="table table-sm align-middle">'
1378 . '<thead><tr><th>Artikel</th><th class="text-end">Menge</th><th class="text-end">Preis</th><th class="text-end">Versand</th><th class="text-end">Summe</th></tr></thead>'
1379 . '<tbody>' . $rows . '</tbody>'
1380 . '<tfoot><tr><th colspan="4" class="text-end">Zahlbetrag</th><th class="text-end">' . $this->money($sum) . '</th></tr></tfoot>'
1381 . '</table></div>';
1382 }
1383
1384 private function legalSnapshotsForOrder(): array {
1385 $cfg = $this->shopConfig();
1386 if (!$this->settingsBool($cfg, 'legal_snapshot_enabled', true)) {
1387 return array('', '');
1388 }
1389 $db = $this->contentDb();
1390 if (!is_object($db)) {
1391 return array('', '');
1392 }
1393 $pages = $this->ensureShopLegalPages();
1395 $snapshot = function(string $key) use ($db, $pages, $dd): string {
1396 $cid = (int)($pages[$key] ?? 0);
1397 if ($cid <= 0) {
1398 return '';
1399 }
1400 $row = $db->select1($dd, $cid, 'title,permalink,content,update_date', 0);
1401 if (!is_array($row)) {
1402 return '';
1403 }
1404 return json_encode(array(
1405 'captured_at' => date('Y-m-d H:i:s'),
1406 'title' => (string)($row['title'] ?? ''),
1407 'permalink' => (string)($row['permalink'] ?? ''),
1408 'update_date' => (string)($row['update_date'] ?? ''),
1409 'content' => (string)($row['content'] ?? ''),
1410 ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ?: '';
1411 };
1412 return array($snapshot('legal'), $snapshot('withdrawal'));
1413 }
1414
1415 private function orderSuccessPage(array $order, string $paymentMethod): string {
1416 $this->startSession();
1417 $_SESSION['dbxShop_last_order_no'] = (string)($order['order_no'] ?? '');
1418 $methodLabels = $this->paymentMethodLabels();
1419 $instructions = trim($this->paymentInstructions($paymentMethod, $order));
1420 $body = '<section class="dbx-shop-order-success">'
1421 . '<div class="dbx-shop-order-success-icon"><i class="bi bi-check2-circle"></i></div>'
1422 . '<h2>Bestellung gespeichert</h2>'
1423 . '<p>Ihre Bestellnummer lautet <strong>' . $this->h($order['order_no'] ?? '') . '</strong>.</p>'
1424 . '<dl>'
1425 . '<dt>Zahlungsart</dt><dd>' . $this->h($methodLabels[$paymentMethod] ?? $paymentMethod) . '</dd>'
1426 . '<dt>Status</dt><dd>Die Bestellung ist im Shop-Admin sichtbar und wartet auf Bearbeitung.</dd>'
1427 . '<dt>Summe</dt><dd>' . $this->money($order['total_gross'] ?? 0) . '</dd>'
1428 . '</dl>'
1429 . ($instructions !== '' ? '<div class="alert alert-info text-start"><strong>Zahlungshinweis</strong><br>' . nl2br($this->h($instructions)) . '</div>' : '')
1430 . '<div class="dbx-shop-order-success-actions">'
1431 . '<a class="btn btn-primary" href="?dbx_modul=dbxShop&amp;dbx_run1=orders"><i class="bi bi-receipt"></i> Bestellungen ansehen</a>'
1432 . '<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&amp;dbx_run1=catalog"><i class="bi bi-grid"></i> Weiter einkaufen</a>'
1433 . '</div>'
1434 . '</section>';
1435 return $this->page('Danke', 'Die Bestellung wurde als Snapshot gespeichert.', $body, 'orders');
1436 }
1437
1438 private function shopMailFrom(array $cfg) {
1439 $from = trim((string)($cfg['mail_from'] ?? ''));
1440 if ($from === '') {
1441 $from = 'shop@dbxapp.de';
1442 }
1443 return array('email' => $from, 'name' => 'dbxShop');
1444 }
1445
1446 private function orderMailHtml(array $order, string $title): string {
1447 $items = '';
1448 foreach ((array)($order['items'] ?? array()) as $item) {
1449 $items .= '<li>' . (int)($item['qty'] ?? 0) . 'x ' . $this->h($item['title'] ?? '') . ' - ' . $this->money($item['total_gross'] ?? 0) . '</li>';
1450 }
1451 $provider = (string)($order['payment_provider'] ?? '');
1452 $instructions = trim($this->paymentInstructions($provider, $order));
1453 return '<h1>' . $this->h($title) . '</h1>'
1454 . '<p>Bestellnummer: <strong>' . $this->h($order['order_no'] ?? '') . '</strong></p>'
1455 . '<ul>' . $items . '</ul>'
1456 . '<p>Summe: <strong>' . $this->money($order['total_gross'] ?? 0) . '</strong></p>'
1457 . '<p>Status: ' . $this->h($order['status'] ?? '') . ', Zahlung: ' . $this->h($this->paymentProviderLabel($provider)) . ' / ' . $this->h($order['payment_status'] ?? '') . '</p>'
1458 . ($instructions !== '' ? '<p><strong>Zahlungshinweis</strong><br>' . nl2br($this->h($instructions)) . '</p>' : '');
1459 }
1460
1461 private function sendOrderMails(array $order): void {
1462 $cfg = $this->shopConfig();
1463 $from = $this->shopMailFrom($cfg);
1464 $subject = 'Bestellung ' . (string)($order['order_no'] ?? '');
1465 try {
1466 if ($this->settingsBool($cfg, 'mail_customer_enabled', false)) {
1467 $to = trim((string)($order['customer_email'] ?? ''));
1468 if ($to !== '') {
1469 dbx()->send_mail($from, $to, $subject, $this->orderMailHtml($order, 'Ihre Bestellung'), 'html');
1470 }
1471 }
1472 if ($this->settingsBool($cfg, 'mail_admin_enabled', false)) {
1473 $to = trim((string)($cfg['mail_admin_to'] ?? ''));
1474 if ($to !== '') {
1475 dbx()->send_mail($from, $to, '[Shop] ' . $subject, $this->orderMailHtml($order, 'Neue Shop-Bestellung'), 'html');
1476 }
1477 }
1478 } catch (\Throwable $e) {
1479 dbx()->sys_msg('error', 'dbxShop', (string)($order['id'] ?? ''), 'order mail failed', $e->getMessage());
1480 }
1481 }
1482
1483 private function sendWithdrawalMails(array $withdrawal): void {
1484 $cfg = $this->shopConfig();
1485 $from = $this->shopMailFrom($cfg);
1486 $subject = 'Widerruf ' . (string)($withdrawal['order_no'] ?? '');
1487 $html = '<h1>Widerruf</h1>'
1488 . '<p>Bestellnummer: <strong>' . $this->h($withdrawal['order_no'] ?? '') . '</strong></p>'
1489 . '<p>Name: ' . $this->h($withdrawal['customer_name'] ?? '') . '<br>E-Mail: ' . $this->h($withdrawal['customer_email'] ?? '') . '</p>'
1490 . '<p>' . nl2br($this->h($withdrawal['reason'] ?? '')) . '</p>';
1491 try {
1492 if ($this->settingsBool($cfg, 'mail_customer_enabled', false)) {
1493 $to = trim((string)($withdrawal['customer_email'] ?? ''));
1494 if ($to !== '') {
1495 dbx()->send_mail($from, $to, $subject, $html, 'html');
1496 }
1497 }
1498 if ($this->settingsBool($cfg, 'mail_admin_enabled', false)) {
1499 $to = trim((string)($cfg['mail_admin_to'] ?? ''));
1500 if ($to !== '') {
1501 dbx()->send_mail($from, $to, '[Shop] ' . $subject, $html, 'html');
1502 }
1503 }
1504 } catch (\Throwable $e) {
1505 dbx()->sys_msg('error', 'dbxShop', (string)($withdrawal['id'] ?? ''), 'withdrawal mail failed', $e->getMessage());
1506 }
1507 }
1508
1509 private function publicOrderCard(array $order): string {
1510 $items = '';
1511 foreach ((array)($order['items'] ?? array()) as $item) {
1512 $items .= '<li><span>' . (int)($item['qty'] ?? 0) . 'x ' . $this->h($item['title'] ?? '') . '</span><strong>' . $this->money($item['total_gross'] ?? 0) . '</strong></li>';
1513 }
1514 if ($items === '') {
1515 $items = '<li><span>Keine Positionen gespeichert</span><strong></strong></li>';
1516 }
1517 $statusLabels = array(
1518 'new' => 'Neu',
1519 'payment_pending' => 'Zahlung offen',
1520 'paid' => 'Bezahlt',
1521 'processing' => 'In Bearbeitung',
1522 'shipped' => 'Versendet',
1523 'done' => 'Abgeschlossen',
1524 'cancelled' => 'Storniert',
1525 );
1526 $shippingLabels = array(
1527 'open' => 'Offen',
1528 'ready' => 'Bereit',
1529 'shipped' => 'Versendet',
1530 'delivered' => 'Zugestellt',
1531 'returned' => 'Retoure',
1532 );
1533 $withdrawalLabels = array(
1534 'new' => 'Neu',
1535 'processing' => 'In Bearbeitung',
1536 'accepted' => 'Angenommen',
1537 'rejected' => 'Abgelehnt',
1538 'refunded' => 'Erstattet',
1539 'closed' => 'Abgeschlossen',
1540 );
1541 $historyLabels = array(
1542 'created' => 'Bestellung',
1543 'status' => 'Bestellstatus',
1544 'payment_status' => 'Zahlung',
1545 'shipping_status' => 'Versand',
1546 'invoice_no' => 'Rechnung',
1547 'tracking_no' => 'Tracking',
1548 'payment' => 'Zahlung',
1549 'customer_mail' => 'Kundenmail',
1550 'withdrawal' => 'Widerruf',
1551 'withdrawal_status' => 'Widerrufsstatus',
1552 'stock_release' => 'Bestand',
1553 'invoice_pdf' => 'Rechnung',
1554 );
1555 $invoice = trim((string)($order['invoice_no'] ?? ''));
1556 $trackingNo = trim((string)($order['tracking_no'] ?? ''));
1557 $trackingUrl = trim((string)($order['tracking_url'] ?? ''));
1558 $channel = (string)($order['channel_key'] ?? 'shop');
1559 $extra = '';
1560 $extra .= '<span>Herkunft: ' . $this->h($this->paymentProviderLabel($channel)) . '</span>';
1561 if ($invoice !== '') {
1562 $extra .= '<span>Rechnung: ' . $this->h($invoice) . '</span>';
1563 }
1564 $extra .= '<span>Versand: ' . $this->h($shippingLabels[(string)($order['shipping_status'] ?? 'open')] ?? (string)($order['shipping_status'] ?? 'open')) . '</span>';
1565 if ($trackingNo !== '') {
1566 $trackingText = 'Tracking: ' . $this->h($trackingNo);
1567 $extra .= $trackingUrl !== ''
1568 ? '<span><a href="' . $this->h($trackingUrl) . '" target="_blank" rel="noopener">' . $trackingText . '</a></span>'
1569 : '<span>' . $trackingText . '</span>';
1570 }
1571 $withdrawalsHtml = '';
1572 foreach ((array)($order['withdrawals'] ?? array()) as $withdrawal) {
1573 $status = (string)($withdrawal['status'] ?? 'new');
1574 $created = trim((string)($withdrawal['create_date'] ?? ''));
1575 $withdrawalsHtml .= '<li><span><strong>' . $this->h($withdrawalLabels[$status] ?? $status) . '</strong>' . ($created !== '' ? '<small>' . $this->h($created) . '</small>' : '') . '</span></li>';
1576 }
1577 if ($withdrawalsHtml !== '') {
1578 $withdrawalsHtml = '<section class="dbx-shop-public-order-withdrawals"><h4><i class="bi bi-arrow-counterclockwise"></i> Widerrufe</h4><ul>' . $withdrawalsHtml . '</ul></section>';
1579 }
1580 $historyHtml = '';
1581 $historyCount = 0;
1582 foreach ((array)($order['history'] ?? array()) as $history) {
1583 if ($historyCount >= 6) {
1584 break;
1585 }
1586 $type = (string)($history['event_type'] ?? '');
1587 $created = trim((string)($history['create_date'] ?? ''));
1588 $message = trim((string)($history['message'] ?? ''));
1589 $old = trim((string)($history['old_value'] ?? ''));
1590 $new = trim((string)($history['new_value'] ?? ''));
1591 $detail = $message !== '' ? $message : trim($old . ($old !== '' && $new !== '' ? ' -> ' : '') . $new);
1592 $historyHtml .= '<li><span><strong>' . $this->h($historyLabels[$type] ?? $type) . '</strong>' . ($detail !== '' ? '<small>' . $this->h($detail) . '</small>' : '') . '</span>' . ($created !== '' ? '<time>' . $this->h($created) . '</time>' : '') . '</li>';
1593 $historyCount++;
1594 }
1595 if ($historyHtml !== '') {
1596 $historyHtml = '<section class="dbx-shop-public-order-history"><h4><i class="bi bi-clock-history"></i> Verlauf</h4><ol>' . $historyHtml . '</ol></section>';
1597 }
1598 $instructions = trim($this->paymentInstructions((string)($order['payment_provider'] ?? ''), $order));
1599 $invoiceLink = '';
1600 $canInvoice = $invoice !== '' || in_array((string)($order['status'] ?? ''), array('paid', 'processing', 'shipped', 'done'), true);
1601 if ($canInvoice) {
1602 $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> Rechnung</a>';
1603 }
1604 return '<article class="dbx-shop-public-order">'
1605 . '<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>'
1606 . '<ul>' . $items . '</ul>'
1607 . '<footer><span>Zahlung: ' . $this->h($this->paymentProviderLabel((string)($order['payment_provider'] ?? ''))) . ' / ' . $this->h($order['payment_status'] ?? '') . '</span><strong>' . $this->money($order['total_gross'] ?? 0) . '</strong></footer>'
1608 . ($extra !== '' ? '<div class="dbx-shop-public-order-extra">' . $extra . '</div>' : '')
1609 . ($instructions !== '' && in_array((string)($order['payment_status'] ?? ''), array('open', 'created', 'pending'), true) ? '<div class="alert alert-info py-2 my-2"><strong>Zahlungshinweis</strong><br>' . nl2br($this->h($instructions)) . '</div>' : '')
1610 . $withdrawalsHtml
1611 . $historyHtml
1612 . '<div class="dbx-shop-public-order-actions">' . $invoiceLink . '<a class="btn btn-outline-secondary btn-sm" href="?dbx_modul=dbxShop&amp;dbx_run1=withdrawal"><i class="bi bi-arrow-counterclockwise"></i> Widerruf</a></div>'
1613 . '</article>';
1614 }
1615
1616 private function startPayPalForOrder(array $order): string {
1617 $returnUrl = $this->absoluteShopUrl('paypal_return', array('order_no' => (string)$order['order_no']));
1618 $cancelUrl = $this->absoluteShopUrl('paypal_cancel', array('order_no' => (string)$order['order_no']));
1619 $paypalOrder = $this->paypal()->createOrder($order, $returnUrl, $cancelUrl);
1620 $paypalId = (string)($paypalOrder['id'] ?? '');
1621 $this->repo()->updateOrderPayment((int)$order['id'], 'paypal', 'created', $paypalId, $paypalOrder);
1622 $approvalUrl = $this->paypal()->approvalUrl($paypalOrder);
1623 if ($approvalUrl === '') {
1624 throw new \RuntimeException('PayPal hat keinen Freigabe-Link geliefert.');
1625 }
1626 if (!headers_sent()) {
1627 header('Location: ' . $approvalUrl, true, 302);
1628 exit;
1629 }
1630 return '<a class="btn btn-primary" href="' . $this->h($approvalUrl) . '">Weiter zu PayPal</a>';
1631 }
1632
1633 private function startAmazonPayForOrder(array $order): string {
1634 $returnUrl = $this->absoluteShopUrl('amazon_pay_return', array('order_no' => (string)$order['order_no']));
1635 $cancelUrl = $this->absoluteShopUrl('amazon_pay_cancel', array('order_no' => (string)$order['order_no']));
1636 $checkoutSession = $this->amazonPay()->createCheckoutSession($order, $returnUrl, $cancelUrl);
1637 $checkoutSessionId = (string)($checkoutSession['checkoutSessionId'] ?? $checkoutSession['id'] ?? '');
1638 $this->repo()->updateOrderPayment((int)$order['id'], 'amazon_pay', 'created', $checkoutSessionId, $checkoutSession);
1639 $redirectUrl = $this->amazonPay()->redirectUrl($checkoutSession);
1640 if ($redirectUrl === '') {
1641 throw new \RuntimeException('Amazon Pay hat keinen Redirect-Link geliefert.');
1642 }
1643 if (!headers_sent()) {
1644 header('Location: ' . $redirectUrl, true, 302);
1645 exit;
1646 }
1647 return '<a class="btn btn-primary" href="' . $this->h($redirectUrl) . '">Weiter zu Amazon Pay</a>';
1648 }
1649
1650 public function catalog(): string {
1651 $this->ensureSeed();
1652 $channel = $this->activeChannel();
1653 $query = trim((string)($_GET['q'] ?? ''));
1654 $groupId = $this->catalogGroupId();
1655 $attributeFilters = $this->selectedAttributeFilters();
1656 $matches = array();
1657 $hasQuery = $this->searchTerms($query) !== array();
1658 $currentGroup = $groupId > 0 ? $this->repo()->groupById($groupId) : null;
1659 if ($groupId > 0 && !is_array($currentGroup)) {
1660 $groupId = 0;
1661 }
1662
1663 foreach ($this->repo()->products(true) as $product) {
1664 if (!$this->productHasChannel($product, $channel)) {
1665 continue;
1666 }
1667 if (!$this->productInCatalogGroup($product, $groupId)) {
1668 continue;
1669 }
1670 $score = $this->productSearchScore($product, $query);
1671 if ($score <= 0 || !$this->productMatchesAttributeFilters($product, $attributeFilters)) {
1672 continue;
1673 }
1674
1675 $sku = (string)($product['sku'] ?? '');
1676 if ($sku === '') {
1677 continue;
1678 }
1679 $matches[] = array(
1680 'product' => $product,
1681 'score' => $score,
1682 'sorter' => (int)($product['sorter'] ?? 100),
1683 'title' => (string)($product['title'] ?? ''),
1684 );
1685 }
1686
1687 if ($hasQuery && count($matches) > 1) {
1688 usort($matches, static function(array $a, array $b): int {
1689 if ($a['score'] !== $b['score']) {
1690 return $b['score'] <=> $a['score'];
1691 }
1692 if ($a['sorter'] !== $b['sorter']) {
1693 return $a['sorter'] <=> $b['sorter'];
1694 }
1695 return strcasecmp($a['title'], $b['title']);
1696 });
1697 }
1698
1699 $products = array_map(static fn($match) => $match['product'], $matches);
1700 $reportHtml = $products === array()
1701 ? $this->placeholder('Keine Artikel gefunden', 'Fuer Ihre Suche wurden aktuell keine aktiven Shop-Artikel gefunden.')
1702 : $this->catalogReportHtml($products, $channel, $query, $attributeFilters, $groupId);
1703
1704 $isPaginationAjax = (int)dbx()->get_system_var('dbx_ajax', 0, 'int') === 1
1705 && (array_key_exists('dbx_rpos', $_GET) || array_key_exists('dbx_rrows', $_GET));
1706 if ($isPaginationAjax) {
1707 return $reportHtml;
1708 }
1709
1710 $navigation = $this->catalogGroupBreadcrumb($groupId) . $this->catalogGroupNavigation($groupId);
1711 if ($navigation === '') {
1712 $navigation = $this->catalogGroupNavigation(0);
1713 }
1714 $title = is_array($currentGroup) ? (string)($currentGroup['title'] ?? 'Shop') : 'Shop';
1715 $subtitle = is_array($currentGroup) ? 'Artikelgruppe und passende Artikel.' : 'Artikel, Merchandise, Dienstleistungen und digitale Pakete.';
1716
1717 return $this->page(
1718 $title,
1719 $subtitle,
1720 $this->demoShopNoticeHtml('dbx-shop-demo-catalog-notice', 'dbx-shop-demo-alert-catalog')
1721 . $navigation
1722 . $this->catalogFiltersHtml($channel, $query, $attributeFilters, $groupId)
1723 . $reportHtml,
1724 'catalog'
1725 );
1726 }
1727
1728 public function product(): string {
1729 $this->ensureSeed();
1730 $channel = $this->activeChannel();
1731 $sku = dbx()->get_modul_var('sku', '', 'parameter');
1732 $product = $this->repo()->productBySku((string) $sku);
1733
1734 if (!$product || !$this->productHasChannel($product, $channel)) {
1735 return $this->page(
1736 'Produkt',
1737 'Produkt wurde nicht gefunden oder ist fuer diesen Channel nicht aktiv.',
1738 $this->placeholder('Produkt nicht gefunden', 'Der angeforderte Artikel existiert nicht oder ist fuer den gewaehlten Channel nicht freigegeben.'),
1739 'catalog'
1740 );
1741 }
1742
1743 $body = $this->renderProductDetail($product, $channel);
1744
1745 return $this->page(
1746 $product['title'] ?? 'Produkt',
1747 $product['summary'] ?? 'Artikel',
1748 $body,
1749 'catalog'
1750 );
1751 }
1752
1753 public function cart(): string {
1754 $this->ensureSeed();
1755 $channel = $this->activeChannel();
1756 $ajax = (int)dbx()->get_system_var('dbx_ajax', 0, 'int') === 1;
1757 $addSku = (string)dbx()->get_modul_var('sku', '', 'parameter');
1758 $removeSku = (string)dbx()->get_modul_var('remove', '', 'parameter');
1759 if ((string)dbx()->get_modul_var('clear', '', 'parameter') === '1') {
1760 $this->startSession();
1761 $_SESSION['dbxShop_cart'] = array();
1762 } elseif ($removeSku !== '') {
1763 $this->removeFromCart($removeSku);
1764 } elseif (isset($_POST['shop_cart_update']) && is_array($_POST['qty'] ?? null)) {
1765 $this->updateCartQuantities($_POST['qty']);
1766 } elseif ($addSku !== '') {
1767 $qty = $this->requestedQuantity(dbx()->get_modul_var('qty', '1', 'parameter'));
1768 $this->addToCart($addSku, $qty);
1769 $product = $this->repo()->productBySku($addSku);
1770 if ($product) {
1771 return $this->page(
1772 'Warenkorb',
1773 'Der Artikel wurde hinzugefuegt.',
1774 $this->addedToCartDialog($product),
1775 'cart'
1776 );
1777 }
1778 }
1779
1780 $body = $this->cartBodyHtml();
1781 if ($ajax) {
1782 return $body;
1783 }
1784
1785 return $this->page(
1786 'Warenkorb',
1787 'Artikel, Mengen, MwSt.-Grundlage und Versandkosten.',
1788 $body,
1789 'cart'
1790 );
1791 }
1792
1793 public function checkout(): string {
1794 $this->ensureSeed();
1795 [$rows, $sum] = $this->cartRowsAndSum();
1796 if ($rows === '') {
1797 return $this->page(
1798 'Kasse',
1799 'Erst Artikel auswaehlen, dann bezahlen.',
1800 $this->placeholder('Der Warenkorb ist leer', 'Waehlen Sie im Katalog einen Artikel und legen Sie ihn in den Warenkorb.'),
1801 'checkout'
1802 );
1803 }
1804 $cfg = $this->shopConfig();
1805 if (!$this->settingsBool($cfg, 'checkout_guest_allowed', true) && (int)dbx()->user() <= 0) {
1806 return $this->page(
1807 'Kasse',
1808 'Anmeldung erforderlich.',
1809 '<div class="alert alert-warning m-3"><strong>Anmeldung erforderlich.</strong><br>Gastbestellungen sind aktuell deaktiviert. Bitte melden Sie sich an, bevor Sie die Bestellung abschliessen.</div>',
1810 'checkout'
1811 );
1812 }
1813
1814 $paymentOptions = $this->checkoutPaymentOptions();
1815 $form = dbx()->get_system_obj('dbxForm');
1816 $form->init('shop-checkout-form', 'shop-checkout-form');
1817 $form->_fd = 'dbxShop|checkout';
1818 $form->_action = '?dbx_modul=dbxShop&dbx_run1=checkout';
1819 $form->_data = array(
1820 'customer_name' => (string)($_POST['customer_name'] ?? ''),
1821 'customer_email' => (string)($_POST['customer_email'] ?? ''),
1822 'customer_phone' => (string)($_POST['customer_phone'] ?? ''),
1823 'shipping_address' => (string)($_POST['shipping_address'] ?? ''),
1824 'note' => (string)($_POST['note'] ?? ''),
1825 'payment_method' => (string)($_POST['payment_method'] ?? array_key_first($paymentOptions)),
1826 'accept_legal' => !empty($_POST['accept_legal']) ? 1 : 0,
1827 'accept_withdrawal' => !empty($_POST['accept_withdrawal']) ? 1 : 0,
1828 );
1829 $form->_msg_info = 'Bitte Kundendaten, Zahlungsart und Rechtstext-Bestaetigung pruefen.';
1830 $form->add_flds();
1831 $form->add_obj('checkout_cart', 'obj-value', $this->checkoutTableHtml($rows, $sum));
1832 $form->add_rep('payment_help', $this->checkoutPaymentHelp($paymentOptions));
1833 $form->add_rep('demo_shop_notice', $this->demoShopNoticeHtml('dbx-shop-demo-notice'));
1834 $form->add_rep('checkout_error', '');
1835
1836 $submitted = ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && isset($_POST['shop_checkout_submit']);
1837 if ($submitted) {
1838 $form->submit();
1839 $validLegal = !empty($_POST['accept_legal']) && !empty($_POST['accept_withdrawal']);
1840 $paymentMethod = (string)($_POST['payment_method'] ?? '');
1841 $customerName = trim((string)($_POST['customer_name'] ?? ''));
1842 $customerEmail = trim((string)($_POST['customer_email'] ?? ''));
1843 $shippingAddress = trim((string)($_POST['shipping_address'] ?? ''));
1844 $checkoutError = '';
1845 if ($paymentOptions === array()) {
1846 $checkoutError = 'Es ist keine Zahlungsart verfuegbar.';
1847 } elseif ($customerName === '' || strlen($customerName) < 2) {
1848 $checkoutError = 'Bitte den Namen eintragen.';
1849 } elseif (!filter_var($customerEmail, FILTER_VALIDATE_EMAIL)) {
1850 $checkoutError = 'Bitte eine gueltige E-Mail-Adresse eintragen.';
1851 } elseif ($shippingAddress === '' || strlen($shippingAddress) < 8) {
1852 $checkoutError = 'Bitte die Lieferadresse eintragen.';
1853 } elseif (!isset($paymentOptions[$paymentMethod])) {
1854 $checkoutError = 'Bitte eine verfuegbare Zahlungsart waehlen.';
1855 } elseif (!$validLegal) {
1856 $checkoutError = 'Bitte Rechtstexte und Widerrufsbelehrung bestaetigen.';
1857 }
1858
1859 if ($checkoutError !== '') {
1860 $form->add_rep('checkout_error', '<div class="alert alert-warning mb-0">' . $this->h($checkoutError) . '</div>');
1861 } else {
1862 try {
1863 [$legalSnapshot, $withdrawalSnapshot] = $this->legalSnapshotsForOrder();
1864 $order = $this->repo()->createOrderFromItems(
1865 $this->cartItems(),
1866 $this->activeChannel(),
1867 $customerName,
1868 $customerEmail,
1869 trim((string)($_POST['note'] ?? '')),
1870 $paymentMethod,
1871 in_array($paymentMethod, array('paypal', 'amazon_pay'), true) ? 'created' : 'open',
1872 'payment_pending',
1873 trim((string)($_POST['customer_phone'] ?? '')),
1874 $shippingAddress,
1875 $legalSnapshot,
1876 $withdrawalSnapshot
1877 );
1878 if (!$order) {
1879 $form->add_rep('checkout_error', '<div class="alert alert-danger mb-0">Die Bestellung konnte nicht erstellt werden.</div>');
1880 } elseif ($paymentMethod === 'paypal') {
1881 return $this->startPayPalForOrder($order);
1882 } elseif ($paymentMethod === 'amazon_pay') {
1883 return $this->startAmazonPayForOrder($order);
1884 } else {
1885 $this->sendOrderMails($order);
1886 $this->startSession();
1887 $_SESSION['dbxShop_cart'] = array();
1888 return $this->orderSuccessPage($order, $paymentMethod);
1889 }
1890 } catch (\Throwable $e) {
1891 $form->add_rep('checkout_error', '<div class="alert alert-danger mb-0">' . $this->h($e->getMessage()) . '</div>');
1892 }
1893 }
1894 }
1895
1896 return $this->page(
1897 'Kasse',
1898 'Kundendaten pruefen und den Demo-Bestellablauf testen.',
1899 $form->run(),
1900 'checkout'
1901 );
1902 }
1903
1904 public function paypalStart(): string {
1905 $this->ensureSeed();
1906 return $this->checkout();
1907 }
1908
1909 public function paypalReturn(): string {
1910 $this->ensureSeed();
1911 $paypalOrderId = (string)dbx()->get_modul_var('token', '', 'parameter');
1912 $orderNo = (string)dbx()->get_modul_var('order_no', '', 'parameter');
1913 $order = $orderNo !== '' ? $this->repo()->orderByNo($orderNo) : null;
1914 if (!$order && $paypalOrderId !== '') {
1915 $order = $this->repo()->orderByPaymentReference('paypal', $paypalOrderId);
1916 }
1917 if (!$order || $paypalOrderId === '') {
1918 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');
1919 }
1920
1921 try {
1922 $capture = $this->paypal()->capture($paypalOrderId);
1923 $this->repo()->updateOrderPayment((int)$order['id'], 'paypal', 'completed', $paypalOrderId, $capture);
1924 $freshOrder = $this->repo()->orderById((int)$order['id']) ?: $order;
1925 $this->sendOrderMails($freshOrder);
1926 $this->startSession();
1927 $_SESSION['dbxShop_cart'] = array();
1928 $_SESSION['dbxShop_last_order_no'] = (string)($order['order_no'] ?? '');
1929 $body = '<div class="alert alert-success m-3"><strong>Zahlung abgeschlossen.</strong><br>Bestellung ' . $this->h($order['order_no'] ?? '') . ' wurde bezahlt.</div>';
1930 return $this->page('Danke', 'PayPal-Zahlung wurde bestaetigt.', $body, 'orders');
1931 } catch (\Throwable $e) {
1932 $this->repo()->updateOrderPayment((int)$order['id'], 'paypal', 'failed', $paypalOrderId, array('error' => $e->getMessage()));
1933 return $this->page('PayPal', 'Zahlung konnte nicht bestaetigt werden.', '<div class="alert alert-danger m-3">' . $this->h($e->getMessage()) . '</div>', 'checkout');
1934 }
1935 }
1936
1937 public function paypalCancel(): string {
1938 $orderNo = (string)dbx()->get_modul_var('order_no', '', 'parameter');
1939 $order = $orderNo !== '' ? $this->repo()->orderByNo($orderNo) : null;
1940 if ($order) {
1941 $this->repo()->updateOrderPayment((int)$order['id'], 'paypal', 'cancelled', (string)($order['payment_reference'] ?? ''), array('cancelled' => true));
1942 }
1943 return $this->page(
1944 'PayPal abgebrochen',
1945 'Die Zahlung wurde nicht ausgefuehrt.',
1946 '<div class="alert alert-info m-3">Die PayPal-Zahlung wurde abgebrochen. Der Warenkorb bleibt erhalten.</div>',
1947 'checkout'
1948 );
1949 }
1950
1951 public function amazonPayReturn(): string {
1952 $this->ensureSeed();
1953 $checkoutSessionId = (string)dbx()->get_modul_var('checkoutSessionId', '', 'parameter');
1954 if ($checkoutSessionId === '') {
1955 $checkoutSessionId = (string)dbx()->get_modul_var('amazonCheckoutSessionId', '', 'parameter');
1956 }
1957 $orderNo = (string)dbx()->get_modul_var('order_no', '', 'parameter');
1958 $order = $orderNo !== '' ? $this->repo()->orderByNo($orderNo) : null;
1959 if (!$order && $checkoutSessionId !== '') {
1960 $order = $this->repo()->orderByPaymentReference('amazon_pay', $checkoutSessionId);
1961 }
1962 if (!$order || $checkoutSessionId === '') {
1963 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');
1964 }
1965
1966 try {
1967 $result = $this->amazonPay()->completeCheckoutSession($checkoutSessionId, $order);
1968 $httpStatus = (int)($result['_http_status'] ?? 0);
1969 $state = strtolower((string)($result['statusDetails']['state'] ?? ''));
1970 $paymentStatus = ($httpStatus === 202 || $state === 'authorizationinitiated') ? 'pending' : 'completed';
1971 $this->repo()->updateOrderPayment((int)$order['id'], 'amazon_pay', $paymentStatus, $checkoutSessionId, $result);
1972 $freshOrder = $this->repo()->orderById((int)$order['id']) ?: $order;
1973 $this->sendOrderMails($freshOrder);
1974 $this->startSession();
1975 $_SESSION['dbxShop_cart'] = array();
1976 $_SESSION['dbxShop_last_order_no'] = (string)($order['order_no'] ?? '');
1977 $body = '<div class="alert alert-success m-3"><strong>Amazon-Pay-Zahlung verarbeitet.</strong><br>Bestellung ' . $this->h($order['order_no'] ?? '') . ' wurde aktualisiert.</div>';
1978 return $this->page('Danke', 'Amazon-Pay-Zahlung wurde bestaetigt.', $body, 'orders');
1979 } catch (\Throwable $e) {
1980 $this->repo()->updateOrderPayment((int)$order['id'], 'amazon_pay', 'failed', $checkoutSessionId, array('error' => $e->getMessage()));
1981 return $this->page('Amazon Pay', 'Zahlung konnte nicht bestaetigt werden.', '<div class="alert alert-danger m-3">' . $this->h($e->getMessage()) . '</div>', 'checkout');
1982 }
1983 }
1984
1985 public function amazonPayCancel(): string {
1986 $orderNo = (string)dbx()->get_modul_var('order_no', '', 'parameter');
1987 $order = $orderNo !== '' ? $this->repo()->orderByNo($orderNo) : null;
1988 if ($order) {
1989 $this->repo()->updateOrderPayment((int)$order['id'], 'amazon_pay', 'cancelled', (string)($order['payment_reference'] ?? ''), array('cancelled' => true));
1990 }
1991 return $this->page(
1992 'Amazon Pay abgebrochen',
1993 'Die Zahlung wurde nicht ausgefuehrt.',
1994 '<div class="alert alert-info m-3">Die Amazon-Pay-Zahlung wurde abgebrochen. Der Warenkorb bleibt erhalten.</div>',
1995 'checkout'
1996 );
1997 }
1998
1999 public function orders(): string {
2000 $this->ensureSeed();
2001 $this->startSession();
2002 $cards = '';
2003 $seen = array();
2004 $lastOrderNo = (string)($_SESSION['dbxShop_last_order_no'] ?? '');
2005 if ($lastOrderNo !== '') {
2006 $order = $this->repo()->orderByNo($lastOrderNo);
2007 if (is_array($order)) {
2008 $cards .= $this->publicOrderCard($order);
2009 $seen[(string)($order['order_no'] ?? '')] = true;
2010 }
2011 }
2012
2013 $uid = function_exists('dbx') ? (int)dbx()->user() : 0;
2014 foreach ($this->repo()->ordersByUid($uid, 25) as $order) {
2015 $orderNo = (string)($order['order_no'] ?? '');
2016 if ($orderNo !== '' && isset($seen[$orderNo])) {
2017 continue;
2018 }
2019 $cards .= $this->publicOrderCard($order);
2020 }
2021
2022 if ($cards === '') {
2023 $cards = $this->placeholder(
2024 'Noch keine Bestellungen',
2025 'Nach einer Bestellung wird hier die letzte Bestellung angezeigt. Angemeldete Benutzer sehen hier ihre gespeicherten Bestellungen.'
2026 );
2027 } else {
2028 $cards = '<section class="dbx-shop-public-orders">' . $cards . '</section>';
2029 }
2030
2031 return $this->page(
2032 'Bestellungen',
2033 'Ihre gespeicherten Shop-Bestellungen.',
2034 $cards,
2035 'orders'
2036 );
2037 }
2038
2039 private function orderIsPublicAccessible(array $order): bool {
2040 $this->startSession();
2041 $orderNo = (string)($order['order_no'] ?? '');
2042 if ($orderNo !== '' && $orderNo === (string)($_SESSION['dbxShop_last_order_no'] ?? '')) {
2043 return true;
2044 }
2045 $uid = function_exists('dbx') ? (int)dbx()->user() : 0;
2046 return $uid > 0 && (int)($order['uid'] ?? 0) === $uid;
2047 }
2048
2049 public function invoicePdf(): string {
2050 $this->ensureSeed();
2051 $this->startSession();
2052 $orderNo = (string)dbx()->get_modul_var('order_no', '', 'parameter');
2053 $order = $orderNo !== '' ? $this->repo()->orderByNo($orderNo) : null;
2054 if (!is_array($order) || !$this->orderIsPublicAccessible($order)) {
2055 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');
2056 }
2057 $order = $this->repo()->ensureOrderInvoicePdf((int)$order['id']);
2058 if (!is_array($order)) {
2059 return $this->page('Rechnung', 'PDF konnte nicht erzeugt werden.', '<div class="alert alert-danger m-3">Die Rechnungsdatei konnte nicht erzeugt werden.</div>', 'orders');
2060 }
2061 $file = $this->repo()->invoicePdfAbsolutePath($order);
2062 if ($file === '') {
2063 return $this->page('Rechnung', 'PDF konnte nicht geladen werden.', '<div class="alert alert-danger m-3">Die Rechnungsdatei ist nicht verfuegbar.</div>', 'orders');
2064 }
2065 if (!headers_sent()) {
2066 header('Content-Type: application/pdf');
2067 header('Content-Disposition: inline; filename="' . basename($file) . '"');
2068 header('Content-Length: ' . filesize($file));
2069 }
2070 readfile($file);
2071 exit;
2072 }
2073
2074 private function jsonResponse(array $data, int $status = 200): string {
2075 if (!headers_sent()) {
2076 http_response_code($status);
2077 header('Content-Type: application/json; charset=utf-8');
2078 }
2079 return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
2080 }
2081
2082 public function channelWebhook(): string {
2083 $channelKey = (string)dbx()->get_modul_var('channel', '', 'parameter');
2084 $raw = (string)file_get_contents('php://input');
2085 $payload = json_decode($raw, true);
2086 if (!is_array($payload)) {
2087 $payload = $_POST;
2088 }
2089
2090 try {
2091 $channel = $this->repo()->channelByKey($channelKey);
2092 if (!$channel) {
2093 return $this->jsonResponse(array('ok' => false, 'message' => 'Channel nicht gefunden.'), 404);
2094 }
2095 if ((int)($channel['active'] ?? 0) !== 1 || (int)($channel['order_import_enabled'] ?? 0) !== 1) {
2096 return $this->jsonResponse(array('ok' => false, 'message' => 'Order-Import fuer diesen Channel ist nicht aktiv.'), 403);
2097 }
2098
2099 $secret = (string)($channel['webhook_secret'] ?? '');
2100 if ($secret !== '') {
2101 $given = (string)($_SERVER['HTTP_X_DBX_SHOP_SECRET'] ?? $_SERVER['HTTP_X_CHANNEL_SECRET'] ?? $_GET['secret'] ?? $_POST['secret'] ?? $payload['secret'] ?? '');
2102 if ($given === '' || !hash_equals($secret, $given)) {
2103 return $this->jsonResponse(array('ok' => false, 'message' => 'Webhook-Secret ungueltig.'), 403);
2104 }
2105 }
2106
2107 $connector = dbx()->get_include_obj('dbxShopChannelConnector', 'dbxShop');
2108 if (is_object($connector) && method_exists($connector, 'normalizeWebhookPayload')) {
2109 $payload = (array)$connector->normalizeWebhookPayload($channel, $payload);
2110 }
2111
2112 $order = $this->repo()->importChannelOrder($channelKey, $payload);
2113 return $this->jsonResponse(array(
2114 'ok' => true,
2115 'order_no' => (string)($order['order_no'] ?? ''),
2116 'channel' => $channelKey,
2117 ));
2118 } catch (\Throwable $e) {
2119 return $this->jsonResponse(array('ok' => false, 'message' => $e->getMessage()), 400);
2120 }
2121 }
2122
2123 public function legal(): string {
2124 return $this->renderCmsShopPage(
2125 'legal',
2126 'Rechtstexte',
2127 'AGB, Anbieterkennzeichnung, Zahlung, Versand und Datenschutz-Hinweise.',
2128 'legal'
2129 );
2130 }
2131
2132 public function withdrawal(): string {
2133 $pages = $this->ensureShopLegalPages();
2134 $cid = (int)($pages['withdrawal'] ?? 0);
2135 $body = '';
2136 if ($cid > 0) {
2137 $renderer = dbx()->get_include_obj('dbxContentRenderer', 'dbxContent');
2138 $body = is_object($renderer) ? (string)$renderer->renderStatic($cid, array('template' => 'c-body1-footer')) : '';
2139 }
2140 if (trim($body) === '') {
2141 $body = $this->placeholder('Widerruf', 'Die CMS-Seite ist leer.');
2142 }
2143 return $this->page(
2144 'Widerruf',
2145 'Widerrufsbelehrung und Widerruf direkt senden.',
2146 '<div class="dbx-shop-cms-page">' . $body . '</div>' . $this->withdrawalFormHtml(),
2147 'withdrawal'
2148 );
2149 }
2150}
2151?>
$paymentOptions
$cfg
static ddContent(string $lng='')
static afterPageSave($db, int $id, bool $isNew=false)
static afterFolderSave($db, int $id, bool $isNew=false)
exit
Definition index.php:532
if( $syncRequest)
Definition index.php:520
DBX schema administration.
if(! $db->connect_db_server($server)) $result
$_SERVER['REQUEST_URI']
foreach( $topics as $file=> $html)