8 private array $textForms = array();
14 if (isset($this->textForms[
$fd])) {
15 return $this->textForms[
$fd];
18 dbx()->get_system_obj(
'dbxForm',
'use');
19 $form = new \dbxForm();
20 $form->set_form_help_enabled(
false);
22 $form->load_fd_messages();
27 private function tpl() {
28 return dbx()->get_system_obj(
'dbxTPL');
32 return dbx()->get_include_obj(
'dbxShopRepository',
'dbxShop');
36 return dbx()->get_include_obj(
'dbxShopPayPal',
'dbxShop');
40 return dbx()->get_include_obj(
'dbxShopAmazonPay',
'dbxShop');
43 private function h(
$value):
string {
44 return htmlspecialchars((
string)
$value, ENT_QUOTES,
'UTF-8');
47 private function readJsonArray(
$value): array {
48 $data = json_decode((
string)
$value,
true);
49 return is_array($data) ? $data : array();
52 private function money(
$value):
string {
53 $language = strtolower(
54 substr((
string)
dbx()->get_system_var(
'dbx_lng',
'de'), 0, 2)
56 return ($language ===
'en'
57 ? number_format((
float)
$value, 2,
'.',
',')
58 : number_format((
float)
$value, 2,
',',
'.')) .
' EUR';
61 private function shopConfig(): array {
62 $cfg =
dbx()->get_config(
'dbxShop');
63 return is_array(
$cfg) ?
$cfg : array();
66 private function settingsBool(array
$cfg,
string $key,
bool $default =
false):
bool {
67 if (!array_key_exists($key,
$cfg)) {
72 return in_array(strtolower(trim((
string)
$value)), array(
'1',
'true',
'yes',
'on'),
true);
75 private function shopStyle():
string {
76 $file = dirname(__DIR__) .
'/design/css/shop.css';
77 if (!is_file(
$file)) {
80 return '<style>' . file_get_contents(
$file) .
'</style>';
83 private function demoShopNoticeHtml(
string $id =
'',
string $extraClass =
'',
$texts =
null):
string {
84 if (!$this->settingsBool($this->shopConfig(),
'demo_notice_enabled',
true)) {
87 $texts =
$texts ?: $this->texts(
'dbxShop|shop-catalog-filter-form');
88 $idAttribute = $id !==
'' ?
' id="' . $this->h($id) .
'"' :
'';
89 $classAttribute = $extraClass !==
'' ?
' ' . $this->h($extraClass) :
'';
90 return '<div' . $idAttribute .
' class="alert alert-danger dbx-shop-demo-alert' . $classAttribute .
'" role="alert">'
91 .
'<strong><i class="bi bi-exclamation-octagon-fill"></i> '
92 . $this->h(
$texts->get_fd_message(
'demo_title'))
94 . $this->h(
$texts->get_fd_message(
'demo_message'))
98 private function page(
string $title,
string $subtitle,
string $body,
string $active =
'catalog'):
string {
99 return $this->tpl()->get_tpl(
'dbxShop|start', array(
100 'shop_style' => $this->shopStyle(),
101 'title' => $this->h($title),
102 'subtitle' => $this->h($subtitle),
104 'active_catalog' => $active ===
'catalog' ?
'active' :
'',
105 'active_cart' => $active ===
'cart' ?
'active' :
'',
106 'active_checkout' => $active ===
'checkout' ?
'active' :
'',
107 'active_orders' => $active ===
'orders' ?
'active' :
'',
108 'active_legal' => $active ===
'legal' ?
'active' :
'',
109 'active_withdrawal' => $active ===
'withdrawal' ?
'active' :
'',
113 private function contentDb() {
114 $db =
dbx()->get_system_obj(
'dbxDB');
115 if (!is_object(
$db) || !
$db->connect_db_server(
'dbx|dbxContent.db3')) {
121 private function findContentFolder(
$db,
string $name,
int $parentId):
int {
123 $parentId = (int) $parentId;
127 $where =
"name = '" . str_replace(
"'",
"''", $name) .
"' AND parent_id = " . $parentId;
128 $rows =
$db->select(\
dbx\
dbxContent\dbxContentLng::ddFolder(), $where,
'id',
'id',
'ASC',
'', 1, 0, 0);
129 if (!is_array(
$rows) || !isset(
$rows[0][
'id'])) {
132 return (
int)
$rows[0][
'id'];
135 private function nextFolderSorter(
$db,
int $parentId):
string {
136 $parentId = (int) $parentId;
137 $rows =
$db->select(\
dbx\
dbxContent\dbxContentLng::ddFolder(),
'parent_id = ' . $parentId,
'sorter',
'sorter,id',
'DESC',
'', 1, 0, 0);
140 $max = (int) (
$rows[0][
'sorter'] ?? 0);
142 return sprintf(
'%04d', $max + 10);
145 private function nextContentSorter(
$db,
int $folderId):
string {
146 $folderId = (int) $folderId;
147 $rows =
$db->select(\
dbx\
dbxContent\dbxContentLng::ddContent(),
'folder = ' . $folderId,
'sorter',
'sorter,id',
'DESC',
'', 1, 0, 0);
150 $max = (int) (
$rows[0][
'sorter'] ?? 0);
152 return sprintf(
'%04d', $max + 10);
155 private function ensureShopContentFolder(
$db):
int {
156 $folderId = $this->findContentFolder(
$db,
'shop', 0);
164 'sorter' => $this->nextFolderSorter(
$db, 0),
166 'template' =>
'c-body1-footer',
167 'hero_template' =>
'image-hero',
168 'hero_image_id' =>
'parent',
169 'hero_margin_top' =>
'parent',
170 'hero_height' =>
'parent',
171 'hero_variant' =>
'parent',
172 'hero_sticky' =>
'parent',
173 'hero_scroll_layer' =>
'parent',
179 $folderId = (int)
$db->get_insert_id();
180 if ($folderId <= 0) {
190 private function shopLegalPageData(
$db,
int $folderId,
string $title,
string $permalink,
string $content): array {
193 'folder' => $folderId,
194 'title' => substr($title, 0, 254),
195 'permalink' => substr($permalink, 0, 254),
199 'sorter' => $this->nextContentSorter(
$db, $folderId),
200 'template' =>
'c-body1-footer',
201 'hero_template' =>
'parent',
202 'hero_image_id' =>
'parent',
203 'hero_margin_top' =>
'parent',
204 'hero_height' =>
'parent',
205 'hero_variant' =>
'parent',
206 'hero_sticky' =>
'parent',
207 'hero_scroll_layer' =>
'parent',
208 'gallery_template' =>
'image-gallery',
209 'gallery_visible_count' =>
'3',
210 'gallery_image_size' =>
'original',
211 'gallery_lightbox_width' =>
'100vw',
212 'gallery_overflow' =>
'grid',
213 'gallery_click_behavior' =>
'lightbox',
218 private function syncContentPermalink(
int $cid,
string $permalink):
void {
219 if ($cid <= 0 || trim($permalink) ===
'') {
225 private function ensureShopLegalPage(
$db,
int $folderId,
string $title,
string $permalink,
string $content, array $legacyPermalinks = array()):
int {
228 foreach (array_values(array_unique(array_merge(array($permalink), $legacyPermalinks))) as $candidate) {
229 $existing =
$db->select1(
$dd, array(
'permalink' => $candidate),
'id,content,permalink', 0);
230 if (is_array($existing) && (
int)($existing[
'id'] ?? 0) > 0) {
234 if (is_array($existing) && (
int) ($existing[
'id'] ?? 0) > 0) {
235 $id = (int) $existing[
'id'];
236 $storedContent = trim((
string) ($existing[
'content'] ??
''));
237 if ($storedContent ===
'') {
238 $data = $this->shopLegalPageData(
$db, $folderId, $title, $permalink,
$content);
239 unset($data[
'sorter'], $data[
'folder']);
240 $db->update(
$dd, $data, $id, 0, 1, 1, 0);
243 $db->update(
$dd, array(
'permalink' => $permalink,
'template' =>
'c-body1-footer',
'group_read' =>
'*',
'activ' => 1), $id, 0, 1, 1, 0);
245 $this->syncContentPermalink($id, $permalink);
249 $data = $this->shopLegalPageData(
$db, $folderId, $title, $permalink,
$content);
250 $ok = (int)
$db->insert(
$dd, $data, 0, 1, 0, 0);
254 $id = (int)
$db->get_insert_id();
260 $this->syncContentPermalink($id, $permalink);
266 $db = $this->contentDb();
267 if (!is_object(
$db)) {
270 $folderId = $this->ensureShopContentFolder(
$db);
271 if ($folderId <= 0) {
276 'legal' => $this->ensureShopLegalPage(
$db, $folderId,
'Rechtstexte',
'shop-rechtstexte', $this->defaultLegalContent(), array(
'shop/rechtstexte')),
277 'withdrawal' => $this->ensureShopLegalPage(
$db, $folderId,
'Widerruf',
'shop-widerruf', $this->defaultWithdrawalContent(), array(
'shop/widerruf')),
281 private function renderCmsShopPage(
string $key,
string $title,
string $subtitle,
string $active): string {
282 $pages = $this->ensureShopLegalPages();
283 $cid = (int) (
$pages[$key] ?? 0);
285 return $this->page($title, $subtitle, $this->placeholder($title,
'Die CMS-Seite konnte nicht angelegt oder geladen werden.'), $active);
288 $renderer =
dbx()->get_include_obj(
'dbxContentRenderer',
'dbxContent');
289 $body = is_object(
$renderer) ? (string)
$renderer->renderStatic($cid, array(
'template' =>
'c-body1-footer')) :
'';
290 if (trim(
$body) ===
'') {
291 $body = $this->placeholder($title,
'Die CMS-Seite ist leer.');
293 return $this->page($title, $subtitle,
'<div class="dbx-shop-cms-page">' .
$body .
'</div>', $active);
302 private function withdrawalFormHtml(
$form): string {
303 $form->_action =
'?dbx_modul=dbxShop&dbx_run1=withdrawal';
304 $form->_data = array_merge(
$form->_data, array(
305 'order_no' => (
string)(
$_POST[
'order_no'] ??
''),
306 'customer_name' => (
string)(
$_POST[
'customer_name'] ??
''),
307 'customer_email' => (
string)(
$_POST[
'customer_email'] ??
''),
308 'customer_address' => (
string)(
$_POST[
'customer_address'] ??
''),
309 'reason' => (
string)(
$_POST[
'reason'] ??
''),
311 $form->add_module_bar(
312 $form->get_fd_message(
'bar_title'),
313 'bi-arrow-counterclockwise',
314 $form->get_fd_message(
'bar_subtitle')
316 $form->_msg_info =
$form->get_fd_message(
'form_info');
317 $form->_msg_error =
$form->get_fd_message(
'validation_error');
318 $form->add_rep(
'bar_title',
$form->get_fd_message(
'bar_title'));
320 if (
$form->submit()) {
321 if (
$form->errors()) {
327 'order_no' =>
$form->get_post(
'order_no',
'',
'parameter|max=40'),
328 'customer_name' =>
$form->get_post_data(
'customer_name',
'',
'*|min=2|max=180'),
329 'customer_email' =>
$form->get_post(
'customer_email',
'',
'email|max=180'),
330 'customer_address' =>
$form->get_post_data(
'customer_address',
'',
'*|min=8|max=2000'),
331 'reason' =>
$form->get_post_data(
'reason',
'',
'*|max=3000'),
334 $row = $this->repo()->saveWithdrawal($values);
335 if (is_array($row)) {
336 $this->sendWithdrawalMails($row);
337 foreach (array_keys($values) as $fieldName) {
338 $form->set_fld_val($fieldName,
'');
354 private function defaultLegalContent(): string {
356<div class=
"dbx-shop-legal-text">
358 <p class=
"dbx-shop-legal-note"><strong>Hinweis:</strong> Dies ist ein Mustertext fuer einen deutschen Online-Shop. Alle Platzhalter in eckigen Klammern muessen vor der Veroeffentlichung durch echte Betreiber-, Register-, Steuer-, Zahlungs- und Versanddaten ersetzt und rechtlich geprueft werden.</p>
360 <h2>Anbieterkennzeichnung</h2>
361 <p><strong>[Name/Firma des Shop-Betreibers]</strong><br>
362 [Strasse und Hausnummer]<br>
365 <p>Vertreten durch: [vertretungsberechtigte Person]<br>
366 E-Mail: <a href=
"mailto:[E-Mail-Adresse]">[E-Mail-Adresse]</a><br>
367 Telefon: [Telefonnummer]</p>
368 <p>Registereintrag: [Registergericht und Registernummer, falls vorhanden]<br>
369 Umsatzsteuer-ID: [USt-IdNr., falls vorhanden]<br>
370 Wirtschafts-ID: [W-IdNr., falls vorhanden]</p>
371 <p>Zustaendige Aufsichtsbehoerde: [nur eintragen, wenn fuer die Taetigkeit erforderlich]</p>
373 <h2>Geltungsbereich</h2>
374 <p>Diese Rechtstexte gelten fuer Bestellungen ueber diesen Shop. Abweichende Bedingungen von Kunden gelten nur, wenn der Shop-Betreiber ihnen ausdruecklich zustimmt.</p>
376 <h2>Vertragspartner und Vertragsschluss</h2>
377 <p>Der Kaufvertrag kommt zustande mit <strong>[Name/Firma des Shop-Betreibers]</strong>. Die Darstellung der Produkte im Shop ist kein rechtlich bindendes Angebot, sondern eine Aufforderung zur Bestellung. Der Kunde gibt ein verbindliches Angebot ab, wenn er den Bestellprozess abschliesst. Die Annahme erfolgt durch Bestellbestaetigung, Zahlungsaufforderung, Versandbestaetigung oder Lieferung der Ware.</p>
379 <h2>Preise, Zahlung und Rechnung</h2>
380 <p>Alle Preise verstehen sich in Euro und enthalten die gesetzliche Umsatzsteuer, sofern diese im Shop ausgewiesen wird. Zusaetzliche Versandkosten werden vor Abgabe der Bestellung angezeigt. Verfuegbare Zahlungsarten sind: [Zahlungsarten eintragen, z. B. PayPal, Ueberweisung, Rechnung]. Die Rechnung wird elektronisch oder in Textform bereitgestellt.</p>
382 <h2>Lieferung und Versand</h2>
383 <p>Die Lieferung erfolgt an die vom Kunden angegebene Lieferadresse. Liefergebiete, Versandarten, Versandkosten und Lieferzeiten ergeben sich aus den Angaben im Bestellprozess. Bei digitalen Produkten erfolgt die Bereitstellung per Download, E-Mail, Kundenkonto oder Freischaltung.</p>
385 <h2>Eigentumsvorbehalt</h2>
386 <p>Gelieferte Waren bleiben bis zur vollstaendigen Bezahlung Eigentum des Shop-Betreibers.</p>
388 <h2>Maengelhaftung</h2>
389 <p>Es gilt das gesetzliche Maengelhaftungsrecht. Kunden werden gebeten, offensichtliche Transportschaeden moeglichst schnell beim Zusteller und beim Shop-Betreiber zu melden. Die gesetzlichen Rechte des Kunden bleiben davon unberuehrt.</p>
391 <h2>Digitale Inhalte und Dienstleistungen</h2>
392 <p>Bei digitalen Inhalten, Downloads, Software, Online-Zugaengen oder Dienstleistungen koennen besondere Hinweise zur Vertragsausfuehrung, Kompatibilitaet, Laufzeit, Kuendigung und zum Widerrufsrecht erforderlich sein. Diese Angaben muessen beim jeweiligen Produkt und im Bestellprozess klar dargestellt werden.</p>
394 <h2>Streitbeilegung</h2>
395 <p>Die Europaeische Kommission stellt eine Plattform zur Online-Streitbeilegung bereit: <a href=
"https://ec.europa.eu/consumers/odr/" target=
"_blank" rel=
"noopener">https:
398 <p>Informationen zur Verarbeitung personenbezogener Daten, zu Kontaktformularen, Zahlungsdiensten, Versanddienstleistern, Cookies, Logfiles und Rechten betroffener Personen stehen in der Datenschutzerklaerung: <a href=
"?dbx_modul=dbxContent&dbx_permalink=datenschutz">Datenschutzerklaerung</a>.</p>
403 private function defaultWithdrawalContent(): string {
405<div class=
"dbx-shop-legal-text">
407 <p class=
"dbx-shop-legal-note"><strong>Hinweis:</strong> Dies ist ein Muster fuer Verbraucherbestellungen nach deutschem Recht. Platzhalter muessen ersetzt und die Ausnahmen fuer konkrete Produkte, digitale Inhalte und Dienstleistungen geprueft werden.</p>
409 <h2>Widerrufsrecht</h2>
410 <p>Verbraucher haben grundsaetzlich das Recht, einen Vertrag binnen vierzehn Tagen ohne Angabe von Gruenden zu widerrufen. Die Widerrufsfrist betraegt vierzehn Tage ab dem Tag, an dem der Kunde oder ein benannter Dritter die Ware erhalten hat. Bei digitalen Inhalten oder Dienstleistungen kann der Fristbeginn und das Erloeschen des Widerrufsrechts abweichen.</p>
412 <h2>Ausuebung des Widerrufs</h2>
413 <p>Um das Widerrufsrecht auszuueben, muss der Kunde den Shop-Betreiber mit einer eindeutigen Erklaerung ueber den Entschluss informieren, den Vertrag zu widerrufen. Die Erklaerung kann per Brief, E-Mail oder ueber ein im Shop bereitgestelltes Formular erfolgen.</p>
414 <p><strong>Widerruf an:</strong><br>
415 [Name/Firma des Shop-Betreibers]<br>
416 [Strasse und Hausnummer]<br>
418 E-Mail: <a href=
"mailto:[E-Mail-Adresse]">[E-Mail-Adresse]</a></p>
420 <h2>Folgen des Widerrufs</h2>
421 <p>Wenn der Kunde den Vertrag widerruft, werden alle Zahlungen einschliesslich der Standard-Lieferkosten unverzueglich und spaetestens binnen vierzehn Tagen ab Eingang der Widerrufserklaerung zurueckgezahlt. Fuer die Rueckzahlung wird dasselbe Zahlungsmittel verwendet, das bei der urspruenglichen Transaktion eingesetzt wurde, sofern nichts anderes vereinbart wurde.</p>
422 <p>Bei Waren kann die Rueckzahlung verweigert werden, bis die Ware wieder eingegangen ist oder der Kunde den Nachweis erbracht hat, dass die Ware zurueckgesendet wurde. Der Kunde hat die Ware unverzueglich und spaetestens binnen vierzehn Tagen ab Widerruf zurueckzusenden.</p>
424 <h2>Ruecksendekosten und Wertersatz</h2>
425 <p>Die unmittelbaren Kosten der Ruecksendung traegt [Kunde/Shop-Betreiber - bitte passend eintragen]. Fuer einen Wertverlust der Ware muss der Kunde nur aufkommen, wenn dieser Wertverlust auf einen nicht notwendigen Umgang mit der Ware zurueckzufuehren ist.</p>
427 <h2>Ausschluss oder Erloeschen des Widerrufsrechts</h2>
428 <p>Das Widerrufsrecht kann insbesondere ausgeschlossen oder vorzeitig erloschen sein bei individuell angefertigten Waren, versiegelten Waren aus Hygiene- oder Gesundheitsschutzgruenden nach Entfernung der Versiegelung, schnell verderblichen Waren, bestimmten Dienstleistungen sowie digitalen Inhalten, wenn der Kunde ausdruecklich zugestimmt hat, dass die Ausfuehrung vor Ablauf der Widerrufsfrist beginnt, und die gesetzlich erforderlichen Bestaetigungen erteilt wurden.</p>
430 <h2>Muster-Widerrufsformular</h2>
431 <p>Wenn Sie den Vertrag widerrufen wollen, koennen Sie diesen Text verwenden und an den Shop-Betreiber senden:</p>
432 <div class=
"dbx-shop-withdrawal-form">
433 <p>An [Name/Firma, Anschrift und E-Mail-Adresse des Shop-Betreibers]</p>
434 <p>Hiermit widerrufe ich den von mir abgeschlossenen Vertrag ueber den Kauf der folgenden Waren oder die Erbringung der folgenden Dienstleistung:</p>
435 <p>[Artikel/Dienstleistung eintragen]</p>
436 <p>Bestellt am: [Datum]<br>Erhalten am: [Datum]</p>
437 <p>Name des Kunden: [Name]<br>Anschrift des Kunden: [Anschrift]</p>
438 <p>Datum: [Datum]</p>
439 <p>Unterschrift des Kunden: [nur bei Mitteilung auf Papier]</p>
445 private function mediaUrl(
string $path): string {
446 $path = trim(str_replace(
'\\',
'/', $path));
450 if (preg_match(
'~^https?://~i', $path) || substr($path, 0, 1) ===
'/') {
453 return dbx()->get_base_url() . ltrim($path,
'/');
456 private function mediaItemUrl(array $image,
bool $thumb =
false): string {
457 $mediaId = (int)($image[
'media_id'] ?? 0);
459 $url =
'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' .
$mediaId;
461 $url .=
'&dbx_thumb=1';
465 return $this->mediaUrl((
string)($image[
'image_path'] ??
''));
468 private function productImage(array $product): array {
469 $images = $product[
'images'] ?? array();
470 if (is_array($images) && isset($images[0]) && is_array($images[0])) {
474 'image_path' =>
'files/shop/img/software-dashboard.svg',
475 'title' => $product[
'title'] ??
'Artikel',
476 'alt' => $product[
'title'] ??
'Artikel',
480 private function primaryGroup(array $product): array {
481 return is_array($product[
'groups'] ?? null) ? (($product[
'groups'] ?? array())[0] ?? array()) : array();
484 private function templateName(
string $value,
string $fallback,
string $prefix =
''): string {
485 $value = preg_replace(
'~[^a-z0-9_-]+~i',
'', trim(
$value));
489 if ($prefix !==
'' && strpos(
$value, $prefix) !== 0) {
495 private function shopTemplateExists(
string $template): bool {
498 return is_file(dirname(__DIR__) .
'/tpl/htm/' .
$template .
'.htm');
507 private function shopTemplateFields(
string $template): array {
508 static $cache = array();
516 if (is_string(
$source) && preg_match_all(
'~\\{([a-z][a-z0-9_]*)\\}~i',
$source, $matches)) {
517 foreach ($matches[1] as
$field) {
525 private function mediaTemplateExists(
string $template): bool {
528 return is_file(dirname(dirname(__DIR__)) .
'/dbxContent/tpl/htm/media-' .
$template .
'.htm');
531 private function groupSetting(array $product,
string $key,
$fallback) {
532 $group = $this->primaryGroup($product);
537 private function productVisual(array $product,
string $class =
''): string {
538 $image = $this->productImage($product);
539 $src = $this->mediaItemUrl($image,
true);
540 $alt = (string)($image[
'alt'] ?? $image[
'title'] ?? $product[
'title'] ??
'');
541 $count = count($product[
'images'] ?? array());
542 $html =
'<div class="dbx-shop-product-visual ' . $this->h(
$class) .
'">';
543 $html .=
'<img class="dbx-shop-product-img" src="' . $this->h($src) .
'" alt="' . $this->h($alt) .
'" loading="lazy">';
544 $html .=
'<span class="dbx-shop-badge">' . $this->h($product[
'badge'] ??
'Artikel') .
'</span>';
546 $html .=
'<span class="dbx-shop-image-count"><i class="bi bi-images"></i> ' . (int)
$count .
'</span>';
552 private function productGallery(array $product): string {
553 $images = $product[
'images'] ?? array();
554 if (!is_array($images) || $images === array()) {
555 return $this->productVisual($product,
'dbx-shop-product-visual-large');
557 $overflow = preg_replace(
'~[^a-z0-9_-]~i',
'', (
string)$this->groupSetting($product,
'gallery_overflow',
'grid')) ?:
'grid';
558 $click = preg_replace(
'~[^a-z0-9_-]~i',
'', (
string)$this->groupSetting($product,
'gallery_click',
'lightbox')) ?:
'lightbox';
559 $visible = max(1, (
int)$this->groupSetting($product,
'gallery_visible_count', 3));
560 $imgSize = preg_replace(
'~[^a-z0-9_-]~i',
'', (
string)$this->groupSetting($product,
'gallery_image_size',
'original')) ?:
'original';
561 $lightboxWidth = preg_replace(
'~[^a-z0-9%._-]+~i',
'', (
string)$this->groupSetting($product,
'gallery_lightbox_width',
'100vw')) ?:
'100vw';
562 $template = preg_replace(
'~[^a-z0-9_-]+~i',
'', strtolower((
string)$this->groupSetting($product,
'gallery_template',
'image-gallery'))) ?:
'image-gallery';
563 if (!$this->mediaTemplateExists(
$template)) {
566 $html =
'<div class="dbx-shop-product-gallery dbx-content-media-gallery gallery-list gallery-template-' . $this->h(
$template) .
'" data-dbx="lib=gallery|overflow=' . $this->h($overflow) .
'|click=' . $this->h($click) .
'|img-count=' . $visible .
'|img-size=' . $this->h($imgSize) .
'|lightbox-width=' . $this->h($lightboxWidth) .
'">';
567 foreach ($images as $image) {
568 $url = $this->mediaItemUrl($image,
false);
569 $thumbUrl = $this->mediaItemUrl($image,
true);
573 $title = (string)($image[
'title'] ?? $product[
'title'] ??
'');
574 $alt = (string)($image[
'alt'] ?? $title);
576 $html .= $this->tpl()->get_tpl(
'dbxContent|media-' .
$template, array(
577 'id' => (
string)($image[
'media_id'] ??
''),
578 'url' => $this->h(
$url),
579 'thumb_url' => $this->h($thumbUrl),
580 'poster_url' => $this->h($thumbUrl),
581 'media_type' =>
'image',
582 'title' => $this->h($title),
583 'alt' => $this->h($alt),
584 'caption' => $this->h($caption),
593 private function placeholder(
string $headline,
string $text, array
$items = array()): string {
595 foreach (
$items as $item) {
596 $list .=
'<li>' . $this->h($item) .
'</li>';
599 return $this->tpl()->get_tpl(
'dbxShop|placeholder', array(
600 'headline' => $this->h($headline),
601 'text' => $this->h($text),
602 'items' => $list !==
'' ?
'<ul>' . $list .
'</ul>' :
'',
606 private function ensureSeed(): void {
609 $this->repo()->install();
612 private function activeChannel(): string {
616 private function channelNav(
string $active): string {
617 $channels = $this->repo()->channels();
618 $html =
'<div class="dbx-shop-channel-nav">';
619 foreach ($channels as $channel) {
620 $key = (string)($channel[
'channel_key'] ??
'');
624 $cls = $key === $active ?
' active' :
'';
625 $html .=
'<a class="btn btn-outline-secondary btn-sm' . $cls .
'" href="?dbx_modul=dbxShop&dbx_run1=catalog&channel=' . rawurlencode($key) .
'">';
626 $html .= $this->h($channel[
'title'] ?? $key);
633 private function productHasChannel(array $product,
string $channel): bool {
634 foreach (($product[
'channels'] ?? array()) as $ch) {
635 if ((
string)($ch[
'channel_key'] ??
'') === $channel && (
int)($ch[
'active'] ?? 0) === 1) {
642 private function groupsHtml(array $product): string {
644 foreach (($product[
'groups'] ?? array()) as $group) {
645 $groupId = (int)($group[
'id'] ?? 0);
646 $href = $groupId > 0 ?
'?dbx_modul=dbxShop&dbx_run1=catalog&group=' . $groupId :
'';
647 $label = $this->h($group[
'title'] ??
'');
648 $html .= $href !==
''
649 ?
'<a class="dbx-shop-chip" href="' . $href .
'">' . $label .
'</a>'
650 :
'<span class="dbx-shop-chip">' . $label .
'</span>';
655 private function catalogGroupId(): int {
656 return max(0, (int)
dbx()->get_modul_var(
'group', 0,
'int'));
659 private function groupImageUrl(array $group): string {
660 $image = $this->repo()->primaryImageForGroup((int)($group[
'id'] ?? 0));
661 if (is_array($image)) {
662 $url = $this->mediaItemUrl($image,
true);
667 return $this->mediaUrl(
'files/shop/img/software-dashboard.svg');
670 private function catalogGroupBreadcrumb(
int $groupId): string {
674 $path = $this->repo()->groupPath($groupId);
675 if ($path === array()) {
678 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
679 $html =
'<nav class="dbx-shop-group-breadcrumb" aria-label="'
680 . $this->h(
$texts->get_fd_message(
'groups_aria')) .
'">';
681 $html .=
'<a href="?dbx_modul=dbxShop&dbx_run1=catalog">'
682 . $this->h(
$texts->get_fd_message(
'all_products')) .
'</a>';
683 foreach ($path as $group) {
684 $id = (int)($group[
'id'] ?? 0);
685 $title = $this->h($group[
'title'] ??
'');
686 if ($id === $groupId) {
687 $html .=
'<span>' . $title .
'</span>';
689 $html .=
'<a href="?dbx_modul=dbxShop&dbx_run1=catalog&group=' . $id .
'">' . $title .
'</a>';
696 private function catalogGroupNavigation(
int $parentId): string {
697 $groups = $this->repo()->groupsByParent($parentId, true);
701 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
702 $html =
'<section class="dbx-shop-group-grid" aria-label="'
703 . $this->h(
$texts->get_fd_message(
'groups_aria')) .
'">';
705 $id = (int)($group[
'id'] ?? 0);
706 if ($id <= 0)
continue;
707 $title = trim((
string)(
708 $group[
'title'] ??
$texts->get_fd_message(
'group_fallback')
710 $description = trim((
string)($group[
'description'] ??
''));
711 $html .=
'<a class="dbx-shop-group-card" href="?dbx_modul=dbxShop&dbx_run1=catalog&group=' . $id .
'">';
712 $html .=
'<span class="dbx-shop-group-card-image"><img src="' . $this->h($this->groupImageUrl($group)) .
'" alt="' . $this->h($title) .
'" loading="lazy"></span>';
713 $html .=
'<span class="dbx-shop-group-card-body"><strong>' . $this->h($title) .
'</strong>';
714 if ($description !==
'') {
715 $html .=
'<small>' . $this->h($description) .
'</small>';
717 $html .=
'</span></a>';
719 $html .=
'</section>';
723 private function productInCatalogGroup(array $product,
int $groupId): bool {
727 if ((
int)($product[
'product_group_id'] ?? 0) === $groupId) {
730 foreach (($product[
'groups'] ?? array()) as $group) {
731 if ((
int)($group[
'id'] ?? 0) === $groupId) {
738 private function channelsHtml(array $product): string {
740 foreach (($product[
'channels'] ?? array()) as $channel) {
741 if ((
int)($channel[
'active'] ?? 0) !== 1) {
744 $html .=
'<span class="dbx-shop-chip dbx-shop-chip-channel">' . $this->h($channel[
'title'] ?? $channel[
'channel_key'] ??
'') .
'</span>';
749 private function normalizedText(
string $value): string {
751 $value = strtr(
$value, array(
'ä' =>
'ae',
'ö' =>
'oe',
'ü' =>
'ue',
'ß' =>
'ss'));
752 $value = preg_replace(
'~[^a-z0-9]+~',
' ',
$value) ?:
'';
753 return preg_replace(
'~\\s+~',
' ', trim(
$value)) ?:
'';
756 private function attributeText(array $product): string {
758 foreach (($product[
'attributes'] ?? array()) as $attribute) {
759 $value = trim((
string)($attribute[
'display_value'] ?? $attribute[
'value_text'] ??
''));
760 $parts[] = (string)($attribute[
'title'] ??
'');
761 $parts[] = (string)($attribute[
'attr_key'] ??
'');
766 return implode(
' ', $parts);
769 private function groupText(array $product): string {
771 foreach (($product[
'groups'] ?? array()) as $group) {
772 $parts[] = (string)($group[
'title'] ??
'');
773 $parts[] = (string)($group[
'group_key'] ??
'');
774 $parts[] = (string)($group[
'description'] ??
'');
775 $parts[] = (string)($group[
'attribute_notes'] ??
'');
777 return implode(
' ', $parts);
780 private function searchTerms(
string $query): array {
781 $terms = preg_split(
'~\\s+~', $this->normalizedText($query)) ?: array();
782 $stopWords = array_flip(array(
'der',
'die',
'das',
'den',
'dem',
'des',
'ein',
'eine',
'einer',
'einem',
'und',
'oder',
'mit',
'ohne',
'fuer',
'fur',
'von',
'im',
'in',
'am',
'an',
'auf',
'zu'));
784 foreach ($terms as $term) {
786 if ($term ===
'' || isset($stopWords[$term])) {
789 if (strlen($term) < 2 && !ctype_digit($term)) {
794 return array_keys(
$out);
797 private function textMatchesSearchTerm(
string $text,
string $term): bool {
798 return $this->searchFieldScore($text, $term, 1) > 0;
801 private function searchFieldScore(
string $text,
string $term,
int $weight): int {
802 if ($text ===
'' || $term ===
'') {
805 if ($text === $term) {
808 $termLength = strlen($term);
809 $compactText = str_replace(
' ',
'', $text);
810 $compactTerm = str_replace(
' ',
'', $term);
811 if (strpos($text, $term) !==
false || strpos($compactText, $compactTerm) !==
false) {
815 foreach (preg_split(
'~\\s+~', $text) ?: array() as
$token) {
821 $best = max($best, $weight * 6);
824 if ($termLength < 3) {
827 if (strlen(
$token) >= $termLength && strpos(
$token, $term) === 0) {
828 $best = max($best, $weight * 4);
834 && substr(
$token, 0, 3) === substr($term, 0, 3)
835 && abs(strlen(
$token) - $termLength) <= ($termLength >= 7 ? 2 : 1)
836 && levenshtein(
$token, $term) <= ($termLength >= 7 ? 2 : 1)
838 $best = max($best, $weight * 2);
844 private function productSearchScore(array $product,
string $query): int {
845 $terms = $this->searchTerms($query);
846 if ($terms === array()) {
850 $primary = $this->normalizedText(implode(
' ', array(
851 (
string)($product[
'sku'] ??
''),
852 (
string)($product[
'title'] ??
''),
853 (
string)($product[
'category'] ??
''),
854 (
string)($product[
'badge'] ??
''),
855 (
string)($product[
'product_type'] ??
''),
857 $secondary = $this->normalizedText(implode(
' ', array(
858 (
string)($product[
'summary'] ??
''),
859 (
string)($product[
'description'] ??
''),
861 $attributes = $this->normalizedText($this->attributeText($product));
862 $groups = $this->normalizedText($this->groupText($product));
866 $firstTermPrimaryScore = 0;
867 $termCount = count($terms);
869 foreach ($terms as $idx => $term) {
870 $primaryScore = $this->searchFieldScore($primary, $term, 10);
873 $this->searchFieldScore($attributes, $term, 7),
874 $this->searchFieldScore($secondary, $term, 4),
875 $this->searchFieldScore(
$groups, $term, 3)
879 $firstTermPrimaryScore = $primaryScore;
881 if ($termScore > 0) {
883 $score += $termScore;
887 if ($matched === 0) {
890 if ($termCount === 1) {
894 if ($matched === $termCount || $firstTermPrimaryScore > 0 || $score >= 20) {
895 return $score + ($matched * 3);
901 private function attributesInlineHtml(array $product,
int $max = 4): string {
904 foreach (($product[
'attributes'] ?? array()) as $attribute) {
905 $value = trim((
string)($attribute[
'display_value'] ?? $attribute[
'value_text'] ??
''));
906 if (
$value ===
'')
continue;
907 $html .=
'<span class="dbx-shop-attribute-chip"><span>' . $this->h($attribute[
'title'] ??
'') .
'</span><strong>' . $this->h(
$value) .
'</strong></span>';
909 if (
$count >= $max)
break;
911 return $html !==
'' ?
'<div class="dbx-shop-attribute-row">' .
$html .
'</div>' :
'';
914 private function attributesTableHtml(array $product): string {
915 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
917 foreach (($product[
'attributes'] ?? array()) as $attribute) {
918 $value = trim((
string)($attribute[
'display_value'] ?? $attribute[
'value_text'] ??
''));
919 if (
$value ===
'')
continue;
920 $rows .=
'<tr><th>' . $this->h($attribute[
'title'] ??
'') .
'</th><td>' . $this->h(
$value) .
'</td></tr>';
925 return '<div class="dbx-shop-attributes"><h4>'
926 . $this->h(
$texts->get_fd_message(
'attributes_heading'))
927 .
'</h4><table><tbody>' .
$rows .
'</tbody></table></div>';
930 private function selectedAttributeFilters(): array {
931 $raw =
$_GET[
'attr'] ?? array();
932 if (!is_array($raw)) {
936 foreach ($raw as $id =>
$value) {
939 if ($id > 0 &&
$value !==
'') {
946 private function productMatchesQuery(array $product,
string $query): bool {
947 return $this->productSearchScore($product, $query) > 0;
950 private function productMatchesAttributeFilters(array $product, array $filters): bool {
951 if ($filters === array()) {
955 foreach (($product[
'attributes'] ?? array()) as $attribute) {
956 $id = (int)($attribute[
'id'] ?? 0);
957 if ($id <= 0)
continue;
958 $values[$id] = $this->normalizedText((
string)($attribute[
'value_text'] ??
''));
960 foreach ($filters as $id =>
$value) {
961 if (!isset($values[$id]) || $values[$id] !== $this->normalizedText((
string)
$value)) {
968 private function catalogFiltersHtml(
string $channel,
string $query, array $selected,
int $groupId = 0): string {
969 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
971 foreach ($this->repo()->attributeFilterDefinitions() as
$definition) {
974 if ($id <= 0 || !is_array($values) || $values === array())
continue;
975 $label = trim((
string)(
$definition[
'title'] ??
''));
976 $group = trim((
string)(
$definition[
'group_title'] ??
''));
977 $filterFields .=
'<label><span>' . $this->h($group !==
'' ? $group .
': ' . $label : $label) .
'</span><select class="form-select form-select-sm" name="attr[' . $id .
']">';
978 $filterFields .=
'<option value="">'
979 . $this->h(
$texts->get_fd_message(
'all_option'))
981 foreach ($values as
$value) {
982 $sel = isset($selected[$id]) && $this->normalizedText((
string)$selected[$id]) === $this->normalizedText((
string)
$value) ?
' selected' :
'';
983 $filterFields .=
'<option value="' . $this->h(
$value) .
'"' . $sel .
'>' . $this->h(
$value) .
'</option>';
985 $filterFields .=
'</select></label>';
987 $advancedFilters =
'';
988 if ($filterFields !==
'') {
989 $open = $selected !== array() ?
' open' :
'';
990 $advancedFilters .=
'<details class="dbx-shop-filter-advanced"' . $open .
'>';
991 $advancedFilters .=
'<summary><i class="bi bi-sliders"></i> '
992 . $this->h(
$texts->get_fd_message(
'refine_filters'))
994 $advancedFilters .=
'<div class="dbx-shop-filter-row">' . $filterFields .
'</div>';
995 $advancedFilters .=
'</details>';
998 $form =
dbx()->get_system_obj(
'dbxForm');
999 $form->init(
'shop-catalog-filter-form',
'shop-catalog-filter-form');
1000 $form->set_editor_class_file(__FILE__);
1001 $form->_fd =
'dbxShop|shop-catalog-filter-form';
1002 $form->load_fd_messages();
1003 $form->_action =
'?dbx_modul=dbxShop&dbx_run1=catalog';
1004 $form->_data = array(
'q' => $query);
1005 $form->_msg_info =
'';
1006 $form->_msg_success =
'';
1007 $form->_msg_error =
'';
1008 $form->_msg_warning =
'';
1009 $form->add_rep(
'bar_title',
$texts->get_fd_message(
'bar_title'));
1010 $form->add_rep(
'frame_skip_form_wrap',
'1');
1011 $form->add_fld(
'q');
1012 $form->add_rep(
'advanced_filters', $advancedFilters);
1013 $form->add_rep(
'group_hidden', $groupId > 0 ?
'<input type="hidden" name="group" value="' . $groupId .
'">' :
'');
1014 return $form->run();
1023 private function productTemplateData(
1026 bool $detail =
false,
1027 ?array $templateFields =
null
1029 $sku = (string)($product[
'sku'] ??
'');
1030 $uses =
static fn(
string $field): bool => $templateFields === null || isset($templateFields[
$field]);
1032 if ($uses(
'sku')) $data[
'sku'] = $this->h($sku);
1033 if ($uses(
'title')) $data[
'title'] = $this->h($product[
'title'] ??
'');
1034 if ($uses(
'summary')) $data[
'summary'] = $this->h($product[
'summary'] ??
'');
1035 if ($uses(
'description')) {
1036 $data[
'description'] = $this->h($product[
'description'] ?? $product[
'summary'] ??
'');
1038 if ($uses(
'groups')) $data[
'groups'] = $this->groupsHtml($product);
1039 if ($uses(
'channels')) $data[
'channels'] =
'';
1040 if ($uses(
'attributes')) {
1041 $data[
'attributes'] = $detail
1042 ? $this->attributesTableHtml($product)
1043 : $this->attributesInlineHtml($product, 4);
1045 if ($uses(
'attributes_table')) {
1046 $data[
'attributes_table'] = $this->attributesTableHtml($product);
1048 if ($uses(
'gallery')) $data[
'gallery'] = $this->productGallery($product);
1049 if ($uses(
'visual')) $data[
'visual'] = $this->productVisual($product);
1050 if ($uses(
'price')) $data[
'price'] = $this->money($product[
'price_gross'] ?? 0);
1051 if ($uses(
'tax_shipping')) $data[
'tax_shipping'] = $this->taxShippingHtml($product);
1052 if ($uses(
'shipping_info')) $data[
'shipping_info'] = $this->shippingInfoHtml($product);
1053 if ($uses(
'stock_info')) $data[
'stock_info'] = $this->stockInfoHtml($product);
1054 if ($uses(
'buy_form')) $data[
'buy_form'] = $this->buyFormHtml($product);
1055 if ($uses(
'detail_url')) {
1056 $data[
'detail_url'] =
'?dbx_modul=dbxShop&dbx_run1=product&sku=' . rawurlencode($sku);
1058 if ($uses(
'catalog_url')) $data[
'catalog_url'] =
'?dbx_modul=dbxShop&dbx_run1=catalog';
1059 if ($uses(
'cart_url')) {
1060 $data[
'cart_url'] =
'?dbx_modul=dbxShop&dbx_run1=cart&sku=' . rawurlencode($sku);
1062 if ($uses(
'card_class')) {
1063 $data[
'card_class'] = $this->h($this->cssTemplateClass(
1064 (
string)$this->groupSetting($product,
'card_template',
'product-card-default')
1067 if ($uses(
'detail_class')) {
1068 $data[
'detail_class'] = $this->h($this->cssTemplateClass(
1069 (
string)$this->groupSetting($product,
'detail_template',
'product-detail-default')
1075 private function cssTemplateClass(
string $template): string {
1080 private function renderProductCard(array $product,
string $channel): string {
1081 $template = $this->templateName((string)$this->groupSetting($product,
'card_template',
'product-card-default'),
'product-card-default',
'product-card-');
1082 if (!$this->shopTemplateExists(
$template)) {
1085 return $this->tpl()->get_tpl(
1087 $this->productTemplateData($product, $channel,
false, $this->shopTemplateFields(
$template))
1091 private function catalogReportHtml(array $products,
string $channel,
string $query, array $attributeFilters,
int $groupId): string {
1092 $report =
dbx()->get_system_obj(
'dbxReport');
1093 $report->init(
'shop-catalog-report',
'dbxShop|shop-catalog-report');
1094 $report->_fd =
'dbxShop|shop-catalog-filter-form';
1096 $report->set_editor_class_file(__FILE__);
1099 $report->_create_row_select =
false;
1100 $report->_create_row_edit =
false;
1101 $report->_create_row_delete =
false;
1103 $rowsPerPage = max(6, min(48, (
int)
$report->get_fld_val(
'dbx_rrows', 12,
'int')));
1104 $position = max(0, (
int)
$report->get_fld_val(
'dbx_rpos', 0,
'int'));
1105 $filteredCount = count($products);
1106 if ($position >= $filteredCount && $filteredCount > 0) {
1107 $position = max(0, (
int)(floor(($filteredCount - 1) / $rowsPerPage) * $rowsPerPage));
1109 $visibleCandidates = array_slice($products, $position, $rowsPerPage);
1110 $visible = $this->repo()->productsByIds(array_map(
1111 static fn($product) => (
int)($product[
'id'] ?? 0),
1115 foreach ($visible as $product) {
1117 'id' => (
int)($product[
'id'] ?? 0),
1118 'card' => $this->renderProductCard($product, $channel),
1122 $queryParts = array(
1123 'dbx_modul' =>
'dbxShop',
1124 'dbx_run1' =>
'catalog',
1126 if ($query !==
'') {
1127 $queryParts[
'q'] = $query;
1130 $queryParts[
'group'] = $groupId;
1132 foreach ($attributeFilters as $id =>
$value) {
1133 $queryParts[
'attr[' . (int)$id .
']'] = (
string)
$value;
1135 $report->_action =
'?' . http_build_query($queryParts,
'',
'&');
1137 'card' =>
$report->get_fd_message(
'column_products'),
1139 $report->_rpt_format = array(
'card' =>
'html');
1140 $report->_rrows = $rowsPerPage;
1142 $report->_count_all = $filteredCount;
1143 $report->_rcount = $filteredCount;
1148 private function renderProductDetail(array $product,
string $channel): string {
1149 $template = $this->templateName((string)$this->groupSetting($product,
'detail_template',
'product-detail-default'),
'product-detail-default',
'product-detail-');
1150 if (!$this->shopTemplateExists(
$template)) {
1153 $data = $this->productTemplateData(
1159 return $this->tpl()->get_tpl(
'dbxShop|' .
$template, $data);
1162 private function taxShippingHtml(array $product): string {
1163 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
1164 $tax = $this->h(number_format((
float)($product[
'effective_tax_rate'] ?? 0), 2,
',',
'.'));
1165 $shipping = (float)($product[
'effective_shipping_gross'] ?? 0);
1166 $shippingText = $shipping > 0
1167 ? $this->money($shipping) .
' ' .
$texts->get_fd_message(
'shipping_suffix')
1168 :
$texts->get_fd_message(
'free_shipping');
1169 $showTax = $this->settingsBool($this->shopConfig(),
'tax_display_enabled',
true);
1172 $parts[] = $tax .
'% ' .
$texts->get_fd_message(
'tax_label');
1174 $parts[] = $this->h($shippingText);
1175 return '<small>' . implode(
', ', $parts) .
'</small>';
1178 private function shippingInfoHtml(array $product): string {
1179 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
1180 $deliveryTime = trim((
string)($product[
'effective_delivery_time'] ??
''));
1181 $shippingWay = trim((
string)($product[
'effective_shipping_way'] ??
''));
1182 $shipping = (float)($product[
'effective_shipping_gross'] ?? 0);
1183 $shippingText = $shipping > 0
1184 ? $this->money($shipping)
1185 :
$texts->get_fd_message(
'free_shipping');
1188 if ($deliveryTime !==
'') {
1189 $rows .=
'<div class="dbx-shop-shipping-info-row"><i class="bi bi-clock"></i><span>'
1190 . $this->h(
$texts->get_fd_message(
'delivery_time'))
1191 .
': ' . $this->h($deliveryTime) .
'</span></div>';
1193 if ($shippingWay !==
'') {
1194 $rows .=
'<div class="dbx-shop-shipping-info-row"><i class="bi bi-truck"></i><span>'
1195 . $this->h(
$texts->get_fd_message(
'shipping_method'))
1196 .
': ' . $this->h($shippingWay) .
'</span></div>';
1198 $rows .=
'<div class="dbx-shop-shipping-info-row"><i class="bi bi-box-seam"></i><span>'
1199 . $this->h(
$texts->get_fd_message(
'shipping_costs'))
1200 .
': ' . $this->h($shippingText) .
'</span></div>';
1202 return '<div class="dbx-shop-shipping-info">' .
$rows .
'</div>';
1205 private function stockInfoHtml(array $product): string {
1206 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
1207 $cfg = $this->shopConfig();
1208 if (!$this->settingsBool(
$cfg,
'stock_enabled',
false) || (
string)($product[
'product_type'] ??
'') !==
'physical') {
1211 $stock = (int)($product[
'stock'] ?? 0);
1213 return '<div class="alert alert-warning py-2 mb-2"><i class="bi bi-exclamation-triangle"></i> '
1214 . $this->h(
$texts->get_fd_message(
'stock_out')) .
'</div>';
1217 return '<div class="alert alert-info py-2 mb-2"><i class="bi bi-box-seam"></i> '
1218 . $this->h(
$texts->format_fd_message(
1220 array(
'count' => $stock)
1223 return '<div class="dbx-shop-shipping-info-row"><i class="bi bi-box-seam"></i><span>'
1224 . $this->h(
$texts->get_fd_message(
'stock_available'))
1234 private function buyForm(array $product): ?\
dbxForm {
1235 $sku = (string)($product[
'sku'] ??
'');
1239 $cfg = $this->shopConfig();
1240 if ($this->settingsBool(
$cfg,
'stock_enabled',
false) && (
string)($product[
'product_type'] ??
'') ===
'physical' && (
int)($product[
'stock'] ?? 0) <= 0) {
1243 $form =
dbx()->get_system_obj(
'dbxForm');
1244 $form->init(
'shop-buy-' . preg_replace(
'~[^a-z0-9_-]+~i',
'-', strtolower($sku)),
'shop-buy-form');
1245 $form->_fd =
'dbxShop|shop-cart';
1246 $form->load_fd_messages();
1247 $form->set_editor_class_file(__FILE__);
1248 $form->_action =
'?dbx_modul=dbxShop&dbx_run1=cart&sku=' . rawurlencode($sku);
1249 $form->_data = array_merge(
$form->_data, array(
'qty' => 1));
1250 $form->_msg_info =
'';
1251 $form->_msg_success =
'';
1252 $form->_msg_error =
'';
1253 $form->_msg_warning =
'';
1254 $form->add_rep(
'bar_title',
$form->get_fd_message(
'buy_form_title'));
1255 $form->add_rep(
'frame_skip_form_wrap',
'1');
1256 $form->add_rep(
'buy_form_id', preg_replace(
'~[^a-z0-9_-]+~i',
'-', $sku));
1257 $form->add_rep(
'catalog_url',
'?dbx_modul=dbxShop&dbx_run1=catalog');
1260 'dbxShop|shop-field-qty',
1261 label:
$form->get_fd_message(
'label_quantity'),
1267 private function buyFormHtml(array $product): string {
1268 $form = $this->buyForm($product);
1270 $texts = $this->texts(
'dbxShop|shop-cart');
1271 return '<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&dbx_run1=catalog"><i class="bi bi-arrow-left"></i> '
1272 . $this->h(
$texts->get_fd_message(
'back_to_catalog'))
1275 return $form->run();
1278 private function startSession(): void {
1279 if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
1287 private function checkoutRequestId(): string {
1288 $posted = strtolower(trim((string)(
$_POST[
'checkout_request_id'] ??
'')));
1289 if (preg_match(
'/^[a-f0-9]{32,64}$/', $posted) === 1) {
1292 return bin2hex(random_bytes(24));
1295 private function checkoutRequestOrder(
string $requestId): ?array {
1296 $this->startSession();
1297 $requests =
$_SESSION[
'dbxShop_checkout_requests'] ?? array();
1298 $orderNo = is_array($requests) ? (string)($requests[$requestId] ??
'') :
'';
1299 return $orderNo !==
'' ? $this->repo()->orderByNo($orderNo) :
null;
1302 private function rememberCheckoutRequest(
string $requestId, array
$order): void {
1303 $this->startSession();
1304 $requests =
$_SESSION[
'dbxShop_checkout_requests'] ?? array();
1305 $requests = is_array($requests) ? $requests : array();
1306 $requests[$requestId] = (string)(
$order[
'order_no'] ??
'');
1307 $_SESSION[
'dbxShop_checkout_requests'] = array_slice($requests, -25,
null,
true);
1310 private function cartItems(): array {
1311 $this->startSession();
1315 private function cartQuantityTotal(): int {
1317 foreach ($this->cartItems() as $qty) {
1318 $count += max(0, (
int)$qty);
1325 return max(1, min(999, $qty > 0 ? $qty :
$fallback));
1328 private function addToCart(
string $sku,
int $qty = 1): void {
1332 $product = $this->repo()->productBySku($sku);
1336 $this->startSession();
1337 $_SESSION[
'dbxShop_cart'][$sku] = max(0, (
int)(
$_SESSION[
'dbxShop_cart'][$sku] ?? 0)) + $this->requestedQuantity($qty);
1340 private function updateCartQuantities(array $quantities): void {
1341 $this->startSession();
1342 foreach ($quantities as $sku => $qty) {
1343 $sku = (string)$sku;
1344 if (!isset(
$_SESSION[
'dbxShop_cart'][$sku])) {
1347 $_SESSION[
'dbxShop_cart'][$sku] = $this->requestedQuantity($qty);
1351 private function removeFromCart(
string $sku): void {
1356 $this->startSession();
1360 private function addedToCartDialog(array $product): string {
1361 $texts = $this->texts(
'dbxShop|shop-cart');
1362 $title = trim((
string)($product[
'title'] ??
$texts->get_fd_message(
'product')));
1363 $qty = $this->requestedQuantity(
dbx()->get_modul_var(
'qty',
'1',
'parameter'));
1364 $body =
'<div class="dbx-shop-added-dialog" role="dialog" aria-modal="true" aria-labelledby="dbx-shop-added-title">';
1365 $body .=
'<div class="dbx-shop-added-dialog-backdrop"></div>';
1366 $body .=
'<div class="dbx-shop-added-dialog-box">';
1367 $body .=
'<div class="dbx-shop-added-dialog-icon"><i class="bi bi-check2"></i></div>';
1368 $body .=
'<h3 id="dbx-shop-added-title">'
1369 . $this->h(
$texts->get_fd_message(
'added_title'))
1371 $body .=
'<p>' . $this->h($title) .
' <span class="dbx-shop-added-qty">x ' . (int)$qty .
'</span></p>';
1372 $body .=
'<div class="dbx-shop-added-dialog-actions">';
1373 $body .=
'<a class="btn btn-outline-primary" href="?dbx_modul=dbxShop&dbx_run1=cart"><i class="bi bi-cart"></i> '
1374 . $this->h(
$texts->get_fd_message(
'cart_title')) .
'</a>';
1375 $body .=
'<a class="btn btn-primary" href="?dbx_modul=dbxShop&dbx_run1=checkout"><i class="bi bi-credit-card"></i> '
1376 . $this->h(
$texts->get_fd_message(
'checkout')) .
'</a>';
1377 $body .=
'<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&dbx_run1=catalog"><i class="bi bi-grid"></i> '
1378 . $this->h(
$texts->get_fd_message(
'continue_shopping')) .
'</a>';
1385 private function cartRowsAndSum(
bool $editable =
false): array {
1388 foreach ($this->cartItems() as $sku => $qty) {
1389 $product = $this->repo()->productBySku((
string)$sku);
1393 $qty = max(1, (
int)$qty);
1394 $price = (float)($product[
'price_gross'] ?? 0);
1395 $shipping = (float)($product[
'effective_shipping_gross'] ?? 0);
1396 $line = ($price + $shipping) * $qty;
1398 $qtyHtml = $editable
1399 ?
'<input class="form-control form-control-sm dbx-shop-cart-qty" type="number" min="1" max="999" step="1" name="qty[' . $this->h($sku) .
']" value="' . (int)$qty .
'">'
1400 : (string)(int)$qty;
1402 $rows .=
'<td><strong>' . $this->h($product[
'title'] ??
'') .
'</strong><br><small>' . $this->h($sku) .
'</small></td>';
1403 $rows .=
'<td class="text-end">' . $qtyHtml .
'</td>';
1404 $rows .=
'<td class="text-end">' . $this->money($price) .
'</td>';
1405 $rows .=
'<td class="text-end">' . $this->money($shipping) .
'</td>';
1406 $rows .=
'<td class="text-end">' . $this->money($line) .
'</td>';
1409 return array(
$rows, $sum);
1412 private function cartReportDataAndSum(
$texts =
null): array {
1416 foreach ($this->cartItems() as $sku => $qty) {
1417 $product = $this->repo()->productBySku((
string)$sku);
1421 $qty = max(1, (
int)$qty);
1422 $price = (float)($product[
'price_gross'] ?? 0);
1423 $shipping = (float)($product[
'effective_shipping_gross'] ?? 0);
1424 $line = ($price + $shipping) * $qty;
1426 $skuText = (string)$sku;
1429 'remove' =>
'<button class="btn btn-sm btn-outline-danger dbxConfirm dbx-shop-cart-remove" type="submit" name="remove" value="' . $this->h($skuText)
1430 .
'" data-confirm-title="<i class=\'bi bi-trash\'></i> ' . $this->h(
$texts->get_fd_message(
'remove_title'))
1431 .
'" data-confirm="' . $this->h(
$texts->get_fd_message(
'remove_question'))
1432 .
'" data-confirm-hint="<small>' . $this->h(
$texts->get_fd_message(
'remove_hint'))
1433 .
'</small>" data-confirm-buttons="yesno" title="' . $this->h(
$texts->get_fd_message(
'remove_title'))
1434 .
'"><i class="bi bi-trash"></i></button>',
1435 'article' =>
'<strong>' . $this->h($product[
'title'] ??
'') .
'</strong><br><small>' . $this->h($skuText) .
'</small>',
1436 'qty' =>
'<input class="form-control form-control-sm dbx-shop-cart-qty" type="number" min="1" max="999" step="1" name="qty[' . $this->h($skuText) .
']" value="' . (
int)$qty .
'">',
1437 'price' =>
'<span class="dbx-shop-money">' . $this->money($price) .
'</span>',
1438 'shipping' =>
'<span class="dbx-shop-money">' . $this->money($shipping) .
'</span>',
1439 'line' =>
'<span class="dbx-shop-money"><strong>' . $this->money($line) .
'</strong></span>',
1442 return array(
$rows, $sum);
1451 private function cartReport(array
$rows,
float $sum): \
dbxReport {
1452 $report =
dbx()->get_system_obj(
'dbxReport');
1453 $report->init(
'shop-cart-report',
'dbxShop|shop-cart-report');
1454 $report->_fd =
'dbxShop|shop-cart';
1456 $report->set_editor_class_file(__FILE__);
1457 $report->_action =
'?dbx_modul=dbxShop&dbx_run1=cart';
1465 $report->_create_row_select =
false;
1466 $report->_create_row_edit =
false;
1467 $report->_create_row_delete =
false;
1469 'remove' =>
$report->get_fd_message(
'column_action'),
1470 'article' =>
$report->get_fd_message(
'column_article'),
1471 'qty' =>
$report->get_fd_message(
'column_quantity'),
1472 'price' =>
$report->get_fd_message(
'column_price'),
1473 'shipping' =>
$report->get_fd_message(
'column_shipping'),
1474 'line' =>
$report->get_fd_message(
'column_total'),
1478 'article' =>
'html',
1481 'shipping' =>
'html',
1484 $report->add_rep(
'bar_title',
$report->get_fd_message(
'cart_title'));
1485 $report->add_rep(
'cart_sum', $this->money($sum));
1486 $report->add_rep(
'cart_count', (
string)$this->cartQuantityTotal());
1501 $report->add_rep(
'cart_sum', $this->money($sum));
1502 $report->add_rep(
'cart_count', (
string)$this->cartQuantityTotal());
1509 [$reportRows, $sum] = $this->cartReportDataAndSum(
$texts);
1511 if ($reportRows === array()) {
1512 return '<div class="dbx-shop-cart-empty" data-dbx-shop-cart-count="0">'
1513 . $this->placeholder(
1514 $texts->get_fd_message(
'empty_title'),
1515 $texts->get_fd_message(
'empty_message')
1520 return $this->cartReportHtml($reportRows, $sum,
$report);
1523 private function absoluteShopUrl(
string $run, array $params = array()): string {
1524 $query = array_merge(array(
1525 'dbx_modul' =>
'dbxShop',
1528 return dbx()->get_base_url() .
'?' . http_build_query($query,
'',
'&');
1531 private function checkoutPaymentOptions(): array {
1532 $cfg = $this->shopConfig();
1533 $texts = $this->texts(
'dbxShop|checkout');
1535 if ($this->settingsBool(
$cfg,
'payment_bank_transfer_enabled',
true)) {
1536 $options[
'bank_transfer'] =
$texts->get_fd_message(
'payment_bank_transfer');
1538 if ($this->settingsBool(
$cfg,
'payment_invoice_enabled',
false)) {
1541 if ($this->settingsBool(
$cfg,
'payment_paypal_enabled',
false) && $this->paypal()->isConfigured()) {
1544 if ($this->settingsBool(
$cfg,
'payment_amazon_pay_enabled',
false) && $this->amazonPay()->isConfigured()) {
1545 $options[
'amazon_pay'] =
'Amazon Pay';
1550 private function paymentMethodLabels(): array {
1551 $texts = $this->texts(
'dbxShop|checkout');
1553 'bank_transfer' =>
$texts->get_fd_message(
'payment_bank_transfer'),
1554 'invoice' =>
$texts->get_fd_message(
'payment_invoice'),
1555 'paypal' =>
'PayPal',
1556 'amazon_pay' =>
'Amazon Pay',
1560 private function paymentProviderLabel(
string $provider): string {
1561 $labels = $this->paymentMethodLabels();
1562 $texts = $this->texts(
'dbxShop|shop-orders');
1563 $channelLabels = array(
1564 'shop' =>
$texts->get_fd_message(
'provider_shop'),
1565 'amazon' =>
$texts->get_fd_message(
'provider_amazon'),
1566 'ebay' =>
$texts->get_fd_message(
'provider_ebay'),
1567 'kleinanzeigen' =>
$texts->get_fd_message(
'provider_kleinanzeigen'),
1568 'mobile' =>
$texts->get_fd_message(
'provider_mobile'),
1570 return $labels[$provider] ?? $channelLabels[$provider] ?? $provider;
1573 private function paymentInstructions(
string $method, array
$order = array()): string {
1574 $cfg = $this->shopConfig();
1575 $texts = $this->texts(
'dbxShop|checkout');
1576 if (
$method ===
'bank_transfer') {
1578 $intro = trim((
string)(
$cfg[
'payment_bank_transfer_instructions'] ??
''));
1579 if ($intro ===
'') {
1580 $intro =
$texts->get_fd_message(
'bank_transfer_default');
1584 $texts->get_fd_message(
'account_owner') =>
'payment_bank_transfer_account_owner',
1585 'IBAN' =>
'payment_bank_transfer_iban',
1586 'BIC' =>
'payment_bank_transfer_bic',
1587 'Bank' =>
'payment_bank_transfer_bank_name',
1588 ) as $label => $key) {
1591 $lines[] = $label .
': ' .
$value;
1594 if (trim((
string)(
$order[
'order_no'] ??
'')) !==
'') {
1595 $lines[] =
$texts->get_fd_message(
'purpose') .
': ' . (string)
$order[
'order_no'];
1597 return implode(
"\n", $lines);
1600 $text = trim((
string)(
$cfg[
'payment_invoice_instructions'] ??
''));
1601 return $text !==
'' ? $text :
$texts->get_fd_message(
'invoice_default');
1603 if (
$method ===
'amazon_pay') {
1604 return $texts->get_fd_message(
'amazon_default');
1607 return $texts->get_fd_message(
'paypal_default');
1612 private function checkoutPaymentHelp(array
$options): string {
1613 $texts = $this->texts(
'dbxShop|checkout');
1615 return '<div class="alert alert-warning mb-0">' . $this->h(
$texts->get_fd_message(
'payment_none_help')) .
'</div>';
1618 if (isset(
$options[
'bank_transfer'])) {
1619 $parts[] =
'<div><strong>' . $this->h(
$options[
'bank_transfer']) .
'</strong><span>'
1620 . $this->h(
$texts->get_fd_message(
'payment_bank_transfer_help')) .
'</span></div>';
1623 $parts[] =
'<div><strong>' . $this->h(
$options[
'invoice']) .
'</strong><span>'
1624 . $this->h(
$texts->get_fd_message(
'payment_invoice_help')) .
'</span></div>';
1627 $parts[] =
'<div><strong>PayPal</strong><span>'
1628 . $this->h(
$texts->get_fd_message(
'payment_paypal_help')) .
'</span></div>';
1630 if (isset(
$options[
'amazon_pay'])) {
1631 $parts[] =
'<div><strong>Amazon Pay</strong><span>'
1632 . $this->h(
$texts->get_fd_message(
'payment_amazon_help')) .
'</span></div>';
1634 return '<div class="dbx-shop-payment-method-help">' . implode(
'', $parts) .
'</div>';
1637 private function checkoutTableHtml(
string $rows,
float $sum): string {
1638 $texts = $this->texts(
'dbxShop|checkout');
1639 return '<div class="dbx-shop-cart table-responsive">'
1640 .
'<table class="table table-sm align-middle">'
1641 .
'<thead><tr><th>' . $this->h(
$texts->get_fd_message(
'column_product')) .
'</th>'
1642 .
'<th class="text-end">' . $this->h(
$texts->get_fd_message(
'column_quantity')) .
'</th>'
1643 .
'<th class="text-end">' . $this->h(
$texts->get_fd_message(
'column_price')) .
'</th>'
1644 .
'<th class="text-end">' . $this->h(
$texts->get_fd_message(
'column_shipping')) .
'</th>'
1645 .
'<th class="text-end">' . $this->h(
$texts->get_fd_message(
'column_total')) .
'</th></tr></thead>'
1646 .
'<tbody>' .
$rows .
'</tbody>'
1647 .
'<tfoot><tr><th colspan="4" class="text-end">' . $this->h(
$texts->get_fd_message(
'amount_due'))
1648 .
'</th><th class="text-end">' . $this->money($sum) .
'</th></tr></tfoot>'
1652 private function legalSnapshotsForOrder(): array {
1653 $cfg = $this->shopConfig();
1654 if (!$this->settingsBool(
$cfg,
'legal_snapshot_enabled',
true)) {
1655 return array(
'',
'');
1657 $db = $this->contentDb();
1658 if (!is_object(
$db)) {
1659 return array(
'',
'');
1661 $pages = $this->ensureShopLegalPages();
1663 $snapshot =
function(
string $key) use (
$db,
$pages,
$dd):
string {
1664 $cid = (int)(
$pages[$key] ?? 0);
1668 $row =
$db->select1(
$dd, $cid,
'title,permalink,content,update_date', 0);
1669 if (!is_array($row)) {
1672 return json_encode(array(
1673 'captured_at' => date(
'Y-m-d H:i:s'),
1674 'title' => (
string)($row[
'title'] ??
''),
1675 'permalink' => (
string)($row[
'permalink'] ??
''),
1676 'update_date' => (
string)($row[
'update_date'] ??
''),
1677 'content' => (
string)($row[
'content'] ??
''),
1678 ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ?:
'';
1680 return array($snapshot(
'legal'), $snapshot(
'withdrawal'));
1683 private function orderSuccessPage(array
$order,
string $paymentMethod): string {
1684 $this->startSession();
1685 $_SESSION[
'dbxShop_last_order_no'] = (string)(
$order[
'order_no'] ??
'');
1686 $texts = $this->texts(
'dbxShop|checkout');
1687 $methodLabels = $this->paymentMethodLabels();
1689 $body =
'<section class="dbx-shop-order-success">'
1690 .
'<div class="dbx-shop-order-success-icon"><i class="bi bi-check2-circle"></i></div>'
1691 .
'<h2>' . $this->h(
$texts->get_fd_message(
'order_saved_title')) .
'</h2>'
1692 .
'<p>' . $this->h(
$texts->get_fd_message(
'order_number_text')) .
' <strong>' . $this->h(
$order[
'order_no'] ??
'') .
'</strong>.</p>'
1694 .
'<dt>' . $this->h(
$texts->get_fd_message(
'payment_method_label')) .
'</dt><dd>' . $this->h($methodLabels[$paymentMethod] ?? $paymentMethod) .
'</dd>'
1695 .
'<dt>' . $this->h(
$texts->get_fd_message(
'status_label')) .
'</dt><dd>' . $this->h(
$texts->get_fd_message(
'order_waiting')) .
'</dd>'
1696 .
'<dt>' . $this->h(
$texts->get_fd_message(
'total_label')) .
'</dt><dd>' . $this->money(
$order[
'total_gross'] ?? 0) .
'</dd>'
1698 . (
$instructions !==
'' ?
'<div class="alert alert-info text-start"><strong>' . $this->h(
$texts->get_fd_message(
'payment_note')) .
'</strong><br>' . nl2br($this->h(
$instructions)) .
'</div>' :
'')
1699 .
'<div class="dbx-shop-order-success-actions">'
1700 .
'<a class="btn btn-primary" href="?dbx_modul=dbxShop&dbx_run1=orders"><i class="bi bi-receipt"></i> ' . $this->h(
$texts->get_fd_message(
'view_orders')) .
'</a>'
1701 .
'<a class="btn btn-outline-secondary" href="?dbx_modul=dbxShop&dbx_run1=catalog"><i class="bi bi-grid"></i> ' . $this->h(
$texts->get_fd_message(
'continue_shopping')) .
'</a>'
1705 $texts->get_fd_message(
'thanks_title'),
1706 $texts->get_fd_message(
'saved_snapshot_subtitle'),
1712 private function shopMailFrom(array
$cfg): array {
1713 $from = trim((string)(
$cfg[
'mail_from'] ??
''));
1714 $fromName = trim((
string)(
$cfg[
'mail_from_name'] ??
'dbxShop'));
1715 return array(
'email' => $from,
'name' => $fromName);
1718 private function shopMailOptions(array
$cfg, array $extra = array()): array {
1726 private function orderMailHtml(array
$order,
string $title): string {
1728 foreach ((array)(
$order[
'items'] ?? array()) as $item) {
1729 $items .=
'<li>' . (int)($item[
'qty'] ?? 0) .
'x ' . $this->h($item[
'title'] ??
'') .
' - ' . $this->money($item[
'total_gross'] ?? 0) .
'</li>';
1731 $provider = (string)(
$order[
'payment_provider'] ??
'');
1733 return '<h1>' . $this->h($title) .
'</h1>'
1734 .
'<p>Bestellnummer: <strong>' . $this->h(
$order[
'order_no'] ??
'') .
'</strong></p>'
1735 .
'<ul>' .
$items .
'</ul>'
1736 .
'<p>Summe: <strong>' . $this->money(
$order[
'total_gross'] ?? 0) .
'</strong></p>'
1737 .
'<p>Status: ' . $this->h(
$order[
'status'] ??
'') .
', Zahlung: ' . $this->h($this->paymentProviderLabel($provider)) .
' / ' . $this->h(
$order[
'payment_status'] ??
'') .
'</p>'
1741 private function sendOrderMails(array
$order): bool {
1742 $orderId = (int)(
$order[
'id'] ?? 0);
1743 if ($orderId <= 0 || $this->repo()->hasOrderHistoryEvent($orderId,
'notification',
'order_mail')) {
1747 $cfg = $this->shopConfig();
1748 $from = $this->shopMailFrom(
$cfg);
1749 if (filter_var((
string)($from[
'email'] ??
''), FILTER_VALIDATE_EMAIL) ===
false) {
1754 'order mail configuration invalid',
1755 'Der konfigurierte Shop-Absender ist keine gültige E-Mail-Adresse.'
1759 $mailOptions = $this->shopMailOptions(
$cfg);
1760 $subject =
'Bestellung ' . (string)(
$order[
'order_no'] ??
'');
1763 if ($this->settingsBool(
$cfg,
'mail_customer_enabled',
false)) {
1764 $to = trim((
string)(
$order[
'customer_email'] ??
''));
1766 if (
dbx()->
send_mail($from, $to, $subject, $this->orderMailHtml(
$order,
'Ihre Bestellung'),
'html', array(), $mailOptions)) {
1771 if ($this->settingsBool(
$cfg,
'mail_admin_enabled',
false)) {
1772 $to = trim((
string)(
$cfg[
'mail_admin_to'] ??
''));
1774 if (
dbx()->
send_mail($from, $to,
'[Shop] ' . $subject, $this->orderMailHtml(
$order,
'Neue Shop-Bestellung'),
'html', array(), $mailOptions)) {
1779 }
catch (\Throwable $e) {
1780 dbx()->sys_msg(
'error',
'dbxShop', (
string)(
$order[
'id'] ??
''),
'order mail failed', $e->getMessage());
1784 $this->repo()->addOrderHistory($orderId,
'notification',
'',
'order_mail', $sent .
' Bestellmail(s) wurden versendet.');
1790 private function sendWithdrawalMails(array $withdrawal): void {
1791 $cfg = $this->shopConfig();
1792 $from = $this->shopMailFrom(
$cfg);
1793 if (filter_var((
string)($from[
'email'] ??
''), FILTER_VALIDATE_EMAIL) ===
false) {
1797 (
string)($withdrawal[
'id'] ??
''),
1798 'withdrawal mail configuration invalid',
1799 'Der konfigurierte Shop-Absender ist keine gültige E-Mail-Adresse.'
1803 $mailOptions = $this->shopMailOptions(
$cfg);
1804 $subject =
'Widerruf ' . (string)($withdrawal[
'order_no'] ??
'');
1805 $html =
'<h1>Widerruf</h1>'
1806 .
'<p>Bestellnummer: <strong>' . $this->h($withdrawal[
'order_no'] ??
'') .
'</strong></p>'
1807 .
'<p>Name: ' . $this->h($withdrawal[
'customer_name'] ??
'') .
'<br>E-Mail: ' . $this->h($withdrawal[
'customer_email'] ??
'') .
'</p>'
1808 .
'<p>' . nl2br($this->h($withdrawal[
'reason'] ??
'')) .
'</p>';
1810 if ($this->settingsBool(
$cfg,
'mail_customer_enabled',
false)) {
1811 $to = trim((
string)($withdrawal[
'customer_email'] ??
''));
1813 dbx()->send_mail($from, $to, $subject,
$html,
'html', array(), $mailOptions);
1816 if ($this->settingsBool(
$cfg,
'mail_admin_enabled',
false)) {
1817 $to = trim((
string)(
$cfg[
'mail_admin_to'] ??
''));
1819 dbx()->send_mail($from, $to,
'[Shop] ' . $subject,
$html,
'html', array(), $mailOptions);
1822 }
catch (\Throwable $e) {
1823 dbx()->sys_msg(
'error',
'dbxShop', (
string)($withdrawal[
'id'] ??
''),
'withdrawal mail failed', $e->getMessage());
1827 private function publicOrderCard(array
$order): string {
1828 $texts = $this->texts(
'dbxShop|shop-orders');
1830 foreach ((array)(
$order[
'items'] ?? array()) as $item) {
1831 $items .=
'<li><span>' . (int)($item[
'qty'] ?? 0) .
'x ' . $this->h($item[
'title'] ??
'') .
'</span><strong>' . $this->money($item[
'total_gross'] ?? 0) .
'</strong></li>';
1834 $items =
'<li><span>' . $this->h(
$texts->get_fd_message(
'no_items')) .
'</span><strong></strong></li>';
1836 $statusLabels = array(
1837 'new' =>
$texts->get_fd_message(
'status_new'),
1838 'payment_pending' =>
$texts->get_fd_message(
'status_payment_pending'),
1839 'paid' =>
$texts->get_fd_message(
'status_paid'),
1840 'processing' =>
$texts->get_fd_message(
'status_processing'),
1841 'shipped' =>
$texts->get_fd_message(
'status_shipped'),
1842 'done' =>
$texts->get_fd_message(
'status_done'),
1843 'cancelled' =>
$texts->get_fd_message(
'status_cancelled'),
1845 $shippingLabels = array(
1846 'open' =>
$texts->get_fd_message(
'shipping_open'),
1847 'ready' =>
$texts->get_fd_message(
'shipping_ready'),
1848 'shipped' =>
$texts->get_fd_message(
'shipping_shipped'),
1849 'delivered' =>
$texts->get_fd_message(
'shipping_delivered'),
1850 'returned' =>
$texts->get_fd_message(
'shipping_returned'),
1852 $withdrawalLabels = array(
1853 'new' =>
$texts->get_fd_message(
'withdrawal_new'),
1854 'processing' =>
$texts->get_fd_message(
'withdrawal_processing'),
1855 'accepted' =>
$texts->get_fd_message(
'withdrawal_accepted'),
1856 'rejected' =>
$texts->get_fd_message(
'withdrawal_rejected'),
1857 'refunded' =>
$texts->get_fd_message(
'withdrawal_refunded'),
1858 'closed' =>
$texts->get_fd_message(
'withdrawal_closed'),
1860 $historyLabels = array(
1861 'created' =>
$texts->get_fd_message(
'history_created'),
1862 'status' =>
$texts->get_fd_message(
'history_status'),
1863 'payment_status' =>
$texts->get_fd_message(
'history_payment_status'),
1864 'shipping_status' =>
$texts->get_fd_message(
'history_shipping_status'),
1865 'invoice_no' =>
$texts->get_fd_message(
'history_invoice_no'),
1866 'tracking_no' =>
$texts->get_fd_message(
'history_tracking_no'),
1867 'payment' =>
$texts->get_fd_message(
'history_payment'),
1868 'customer_mail' =>
$texts->get_fd_message(
'history_customer_mail'),
1869 'withdrawal' =>
$texts->get_fd_message(
'history_withdrawal'),
1870 'withdrawal_status' =>
$texts->get_fd_message(
'history_withdrawal_status'),
1871 'stock_release' =>
$texts->get_fd_message(
'history_stock_release'),
1872 'invoice_pdf' =>
$texts->get_fd_message(
'history_invoice_pdf'),
1874 $paymentStatus = (string)(
$order[
'payment_status'] ??
'');
1875 $paymentStatusLabel =
$texts->get_fd_message(
'payment_status_' . $paymentStatus, $paymentStatus);
1876 $invoice = trim((
string)(
$order[
'invoice_no'] ??
''));
1877 $trackingNo = trim((
string)(
$order[
'tracking_no'] ??
''));
1878 $trackingUrl = trim((
string)(
$order[
'tracking_url'] ??
''));
1879 $channel = (string)(
$order[
'channel_key'] ??
'shop');
1881 $extra .=
'<span>' . $this->h(
$texts->get_fd_message(
'origin')) .
': ' . $this->h($this->paymentProviderLabel($channel)) .
'</span>';
1882 if ($invoice !==
'') {
1883 $extra .=
'<span>' . $this->h(
$texts->get_fd_message(
'invoice')) .
': ' . $this->h($invoice) .
'</span>';
1885 $extra .=
'<span>' . $this->h(
$texts->get_fd_message(
'shipping')) .
': '
1886 . $this->h($shippingLabels[(
string)(
$order[
'shipping_status'] ??
'open')] ?? (
string)(
$order[
'shipping_status'] ??
'open')) .
'</span>';
1887 if ($trackingNo !==
'') {
1888 $trackingText = $this->h(
$texts->get_fd_message(
'tracking')) .
': ' . $this->h($trackingNo);
1889 $extra .= $trackingUrl !==
''
1890 ?
'<span><a href="' . $this->h($trackingUrl) .
'" target="_blank" rel="noopener">' . $trackingText .
'</a></span>'
1891 :
'<span>' . $trackingText .
'</span>';
1893 $withdrawalsHtml =
'';
1894 foreach ((array)(
$order[
'withdrawals'] ?? array()) as $withdrawal) {
1895 $status = (string)($withdrawal[
'status'] ??
'new');
1896 $created = trim((
string)($withdrawal[
'create_date'] ??
''));
1897 $withdrawalsHtml .=
'<li><span><strong>' . $this->h($withdrawalLabels[$status] ?? $status) .
'</strong>' . (
$created !==
'' ?
'<small>' . $this->h(
$created) .
'</small>' :
'') .
'</span></li>';
1899 if ($withdrawalsHtml !==
'') {
1900 $withdrawalsHtml =
'<section class="dbx-shop-public-order-withdrawals"><h4><i class="bi bi-arrow-counterclockwise"></i> '
1901 . $this->h(
$texts->get_fd_message(
'withdrawals')) .
'</h4><ul>' . $withdrawalsHtml .
'</ul></section>';
1905 foreach ((array)(
$order[
'history'] ?? array()) as $history) {
1906 if ($historyCount >= 6) {
1909 $type = (string)($history[
'event_type'] ??
'');
1910 $created = trim((
string)($history[
'create_date'] ??
''));
1911 $message = trim((
string)($history[
'message'] ??
''));
1912 $old = trim((
string)($history[
'old_value'] ??
''));
1913 $new = trim((
string)($history[
'new_value'] ??
''));
1914 $detail = $message !==
'' ? $message : trim($old . ($old !==
'' && $new !==
'' ?
' -> ' :
'') . $new);
1915 $historyHtml .=
'<li><span><strong>' . $this->h($historyLabels[$type] ?? $type) .
'</strong>' . ($detail !==
'' ?
'<small>' . $this->h($detail) .
'</small>' :
'') .
'</span>' . (
$created !==
'' ?
'<time>' . $this->h(
$created) .
'</time>' :
'') .
'</li>';
1918 if ($historyHtml !==
'') {
1919 $historyHtml =
'<section class="dbx-shop-public-order-history"><h4><i class="bi bi-clock-history"></i> '
1920 . $this->h(
$texts->get_fd_message(
'history')) .
'</h4><ol>' . $historyHtml .
'</ol></section>';
1924 $canInvoice = $invoice !==
'' || in_array((
string)(
$order[
'status'] ??
''), array(
'paid',
'processing',
'shipped',
'done'),
true);
1926 $invoiceLink =
'<a class="btn btn-outline-primary btn-sm" href="?dbx_modul=dbxShop&dbx_run1=invoice_pdf&order_no=' . rawurlencode((
string)(
$order[
'order_no'] ??
'')) .
'" target="_blank" rel="noopener"><i class="bi bi-file-earmark-pdf"></i> '
1927 . $this->h(
$texts->get_fd_message(
'invoice')) .
'</a>';
1929 return '<article class="dbx-shop-public-order">'
1930 .
'<header><div><strong>' . $this->h(
$order[
'order_no'] ??
'') .
'</strong><small>' . $this->h(
$order[
'create_date'] ??
'') .
'</small></div><span class="badge text-bg-primary">' . $this->h($statusLabels[(
string)(
$order[
'status'] ??
'')] ?? (
$order[
'status'] ??
'')) .
'</span></header>'
1931 .
'<ul>' .
$items .
'</ul>'
1932 .
'<footer><span>' . $this->h(
$texts->get_fd_message(
'payment')) .
': '
1933 . $this->h($this->paymentProviderLabel((
string)(
$order[
'payment_provider'] ??
''))) .
' / '
1934 . $this->h($paymentStatusLabel) .
'</span><strong>' . $this->money(
$order[
'total_gross'] ?? 0) .
'</strong></footer>'
1935 . ($extra !==
'' ?
'<div class="dbx-shop-public-order-extra">' . $extra .
'</div>' :
'')
1936 . (
$instructions !==
'' && in_array((
string)(
$order[
'payment_status'] ??
''), array(
'open',
'created',
'pending'),
true)
1937 ?
'<div class="alert alert-info py-2 my-2"><strong>' . $this->h(
$texts->get_fd_message(
'payment_note')) .
'</strong><br>' . nl2br($this->h(
$instructions)) .
'</div>'
1941 .
'<div class="dbx-shop-public-order-actions">' . $invoiceLink
1942 .
'<a class="btn btn-outline-secondary btn-sm" href="?dbx_modul=dbxShop&dbx_run1=withdrawal"><i class="bi bi-arrow-counterclockwise"></i> '
1943 . $this->h(
$texts->get_fd_message(
'withdrawal')) .
'</a></div>'
1947 private function startPayPalForOrder(array
$order): string {
1948 $returnUrl = $this->absoluteShopUrl(
'paypal_return', array(
'order_no' => (string)
$order[
'order_no']));
1949 $cancelUrl = $this->absoluteShopUrl(
'paypal_cancel', array(
'order_no' => (
string)
$order[
'order_no']));
1950 $paypalOrder = $this->readJsonArray(
$order[
'payment_payload'] ??
'');
1951 $paypalId = (string)(
$order[
'payment_reference'] ??
'');
1952 $approvalUrl = $paypalId !==
'' ? $this->paypal()->approvalUrl($paypalOrder) :
'';
1953 if ($paypalId ===
'' || $approvalUrl ===
'') {
1954 $paypalOrder = $this->paypal()->createOrder(
$order, $returnUrl, $cancelUrl);
1956 $paypalId = (string)($paypalOrder[
'id'] ??
'');
1957 if ($paypalId ===
''
1958 || !$this->repo()->updateOrderPayment((
int)
$order[
'id'],
'paypal',
'created', $paypalId, $paypalOrder)) {
1959 throw new \RuntimeException(
'PayPal-Zahlungsreferenz konnte nicht sicher gespeichert werden.');
1961 $approvalUrl = $this->paypal()->approvalUrl($paypalOrder);
1962 if ($approvalUrl ===
'') {
1963 throw new \RuntimeException(
'PayPal hat keinen Freigabe-Link geliefert.');
1965 if (!headers_sent()) {
1966 header(
'Location: ' . $approvalUrl,
true, 302);
1969 return '<a class="btn btn-primary" href="' . $this->h($approvalUrl) .
'">Weiter zu PayPal</a>';
1972 private function startAmazonPayForOrder(array
$order): string {
1973 $returnUrl = $this->absoluteShopUrl(
'amazon_pay_return', array(
'order_no' => (string)
$order[
'order_no']));
1974 $cancelUrl = $this->absoluteShopUrl(
'amazon_pay_cancel', array(
'order_no' => (
string)
$order[
'order_no']));
1975 $checkoutSession = $this->readJsonArray(
$order[
'payment_payload'] ??
'');
1976 $checkoutSessionId = (string)(
$order[
'payment_reference'] ??
'');
1977 $redirectUrl = $checkoutSessionId !==
'' ? $this->amazonPay()->redirectUrl($checkoutSession) :
'';
1978 if ($checkoutSessionId ===
'' || $redirectUrl ===
'') {
1979 $checkoutSession = $this->amazonPay()->createCheckoutSession(
$order, $returnUrl, $cancelUrl);
1981 $checkoutSessionId = (string)($checkoutSession[
'checkoutSessionId'] ?? $checkoutSession[
'id'] ??
'');
1982 if ($checkoutSessionId ===
''
1983 || !$this->repo()->updateOrderPayment((
int)
$order[
'id'],
'amazon_pay',
'created', $checkoutSessionId, $checkoutSession)) {
1984 throw new \RuntimeException(
'Amazon-Pay-Referenz konnte nicht sicher gespeichert werden.');
1986 $redirectUrl = $this->amazonPay()->redirectUrl($checkoutSession);
1987 if ($redirectUrl ===
'') {
1988 throw new \RuntimeException(
'Amazon Pay hat keinen Redirect-Link geliefert.');
1990 if (!headers_sent()) {
1991 header(
'Location: ' . $redirectUrl,
true, 302);
1994 return '<a class="btn btn-primary" href="' . $this->h($redirectUrl) .
'">Weiter zu Amazon Pay</a>';
1997 private function continueCheckoutOrder(array
$order,
string $paymentMethod): string {
1998 $storedMethod = trim((string)(
$order[
'payment_provider'] ??
''));
1999 if ($storedMethod !==
'') $paymentMethod = $storedMethod;
2000 if ($paymentMethod ===
'paypal') {
2001 return $this->startPayPalForOrder(
$order);
2003 if ($paymentMethod ===
'amazon_pay') {
2004 return $this->startAmazonPayForOrder(
$order);
2007 $this->sendOrderMails(
$order);
2008 $this->startSession();
2010 return $this->orderSuccessPage(
$order, $paymentMethod);
2014 $this->ensureSeed();
2015 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
2016 $channel = $this->activeChannel();
2017 $query = trim((
string)(
$_GET[
'q'] ??
''));
2018 $groupId = $this->catalogGroupId();
2019 $attributeFilters = $this->selectedAttributeFilters();
2021 $hasQuery = $this->searchTerms($query) !== array();
2022 $currentGroup = $groupId > 0 ? $this->repo()->groupById($groupId) :
null;
2023 if ($groupId > 0 && !is_array($currentGroup)) {
2027 foreach ($this->repo()->catalogCandidates($channel) as $product) {
2028 if (!$this->productHasChannel($product, $channel)) {
2031 if (!$this->productInCatalogGroup($product, $groupId)) {
2034 $score = $this->productSearchScore($product, $query);
2035 if ($score <= 0 || !$this->productMatchesAttributeFilters($product, $attributeFilters)) {
2039 $sku = (string)($product[
'sku'] ??
'');
2044 'product' => $product,
2046 'sorter' => (
int)($product[
'sorter'] ?? 100),
2047 'title' => (
string)($product[
'title'] ??
''),
2051 if ($hasQuery && count($matches) > 1) {
2052 usort($matches,
static function(array $a, array $b):
int {
2053 if ($a[
'score'] !== $b[
'score']) {
2054 return $b[
'score'] <=> $a[
'score'];
2056 if ($a[
'sorter'] !== $b[
'sorter']) {
2057 return $a[
'sorter'] <=> $b[
'sorter'];
2059 return strcasecmp($a[
'title'], $b[
'title']);
2063 $products = array_map(
static fn($match) => $match[
'product'], $matches);
2065 ? $this->placeholder(
2066 $texts->get_fd_message(
'no_products_title'),
2067 $texts->get_fd_message(
'no_products_message')
2069 : $this->catalogReportHtml($products, $channel, $query, $attributeFilters, $groupId);
2071 $isPaginationAjax = (int)
dbx()->get_system_var(
'dbx_ajax', 0,
'int') === 1
2072 && (array_key_exists(
'dbx_rpos',
$_GET) || array_key_exists(
'dbx_rrows',
$_GET));
2073 if ($isPaginationAjax) {
2077 $navigation = $this->catalogGroupBreadcrumb($groupId) . $this->catalogGroupNavigation($groupId);
2081 $title = is_array($currentGroup) ? (string)($currentGroup[
'title'] ??
'Shop') :
'Shop';
2082 $subtitle =
$texts->get_fd_message(
2083 is_array($currentGroup)
2084 ?
'catalog_group_subtitle'
2085 :
'catalog_subtitle'
2091 $this->demoShopNoticeHtml(
2092 'dbx-shop-demo-catalog-notice',
2093 'dbx-shop-demo-alert-catalog',
2097 . $this->catalogFiltersHtml($channel, $query, $attributeFilters, $groupId)
2104 $this->ensureSeed();
2105 $texts = $this->texts(
'dbxShop|shop-catalog-filter-form');
2106 $channel = $this->activeChannel();
2107 $sku =
dbx()->get_modul_var(
'sku',
'',
'parameter');
2108 $product = $this->repo()->productBySku((
string) $sku);
2110 if (!$product || !$this->productHasChannel($product, $channel)) {
2112 $texts->get_fd_message(
'product_page_title'),
2113 $texts->get_fd_message(
'product_not_found_subtitle'),
2115 $texts->get_fd_message(
'product_not_found_title'),
2116 $texts->get_fd_message(
'product_not_found_message')
2122 $body = $this->renderProductDetail($product, $channel);
2125 $product[
'title'] ??
$texts->get_fd_message(
'product_page_title'),
2126 $product[
'summary'] ??
$texts->get_fd_message(
'product_fallback'),
2133 $this->ensureSeed();
2134 $texts = $this->texts(
'dbxShop|shop-cart');
2135 $channel = $this->activeChannel();
2136 $ajax = (int)
dbx()->get_system_var(
'dbx_ajax', 0,
'int') === 1;
2137 $addSku = (string)
dbx()->get_modul_var(
'sku',
'',
'parameter');
2139 $hasCartPost = isset(
$_POST[
'shop_cart_update']) || isset(
$_POST[
'remove']) || isset(
$_POST[
'clear']);
2142 [$currentRows, $currentSum] = $this->cartReportDataAndSum(
$texts);
2143 if ($currentRows !== array()) {
2144 $cartReport = $this->cartReport($currentRows, $currentSum);
2145 if ($cartReport->submit() && !$cartReport->errors()) {
2146 $removeSku = (string)$cartReport->get_post(
'remove',
'',
'parameter');
2147 $clear = (int)$cartReport->get_post(
'clear', 0,
'int') === 1;
2149 $this->startSession();
2151 } elseif ($removeSku !==
'') {
2152 $this->removeFromCart($removeSku);
2153 } elseif (isset(
$_POST[
'shop_cart_update']) && is_array(
$_POST[
'qty'] ??
null)) {
2157 $this->updateCartQuantities(
$_POST[
'qty']);
2161 } elseif ($addSku !==
'') {
2162 $product = $this->repo()->productBySku($addSku);
2163 $buyForm = is_array($product) ? $this->buyForm($product) : null;
2164 if ($buyForm && $buyForm->submit() && !$buyForm->errors()) {
2165 $qty = $this->requestedQuantity($buyForm->get_post(
'qty', 1,
'int|min=1'));
2166 $this->addToCart($addSku, $qty);
2168 $texts->get_fd_message(
'cart_title'),
2169 $texts->get_fd_message(
'added_subtitle'),
2170 $this->addedToCartDialog($product),
2182 $texts->get_fd_message(
'cart_title'),
2183 $texts->get_fd_message(
'cart_subtitle'),
2190 $this->ensureSeed();
2191 $form =
dbx()->get_system_obj(
'dbxForm');
2192 $form->init(
'shop-checkout-form',
'shop-checkout-form');
2193 $form->_fd =
'dbxShop|checkout';
2194 $form->load_fd_messages();
2195 [
$rows, $sum] = $this->cartRowsAndSum();
2198 $form->get_fd_message(
'page_title'),
2199 $form->get_fd_message(
'empty_subtitle'),
2201 $form->get_fd_message(
'empty_title'),
2202 $form->get_fd_message(
'empty_message')
2207 $cfg = $this->shopConfig();
2208 if (!$this->settingsBool(
$cfg,
'checkout_guest_allowed',
true) && (
int)
dbx()->
user() <= 0) {
2210 $form->get_fd_message(
'page_title'),
2211 $form->get_fd_message(
'login_subtitle'),
2212 '<div class="alert alert-warning m-3">'
2213 .
$form->get_fd_message(
'login_message')
2220 $checkoutRequestId = $this->checkoutRequestId();
2221 $form->_action =
'?dbx_modul=dbxShop&dbx_run1=checkout';
2222 $form->_data = array_merge(
$form->_data, array(
2223 'customer_name' => (
string)(
$_POST[
'customer_name'] ??
''),
2224 'customer_email' => (
string)(
$_POST[
'customer_email'] ??
''),
2225 'customer_phone' => (
string)(
$_POST[
'customer_phone'] ??
''),
2226 'shipping_address' => (
string)(
$_POST[
'shipping_address'] ??
''),
2227 'note' => (
string)(
$_POST[
'note'] ??
''),
2228 'checkout_request_id' => $checkoutRequestId,
2230 'accept_legal' => !empty(
$_POST[
'accept_legal']) ? 1 : 0,
2231 'accept_withdrawal' => !empty(
$_POST[
'accept_withdrawal']) ? 1 : 0,
2233 $form->_msg_info =
$form->get_fd_message(
'form_info');
2234 $form->_msg_error =
$form->get_fd_message(
'validation_error');
2236 $form->add_obj(
'checkout_cart',
'obj-value', $this->checkoutTableHtml(
$rows, $sum));
2240 $this->demoShopNoticeHtml(
'dbx-shop-demo-notice',
'',
$form)
2243 if (
$form->submit()) {
2244 if (
$form->errors()) {
2249 $paymentMethod = (string)
$form->get_post(
'payment_method',
'',
'parameter');
2250 $customerName = trim((
string)
$form->get_post_data(
'customer_name',
'',
'*|min=2|max=180'));
2251 $customerEmail = trim((
string)
$form->get_post(
'customer_email',
'',
'email|max=180'));
2252 $shippingAddress = trim((
string)
$form->get_post_data(
'shipping_address',
'',
'*|min=8|max=2000'));
2253 $customerPhone = trim((
string)
$form->get_post_data(
'customer_phone',
'',
'*|max=80'));
2254 $note = trim((
string)
$form->get_post_data(
'note',
'',
'*|max=2000'));
2255 $acceptLegal = (int)
$form->get_post(
'accept_legal', 0,
'int') === 1;
2256 $acceptWithdrawal = (int)
$form->get_post(
'accept_withdrawal', 0,
'int') === 1;
2258 $checkoutError =
'';
2260 $checkoutError =
$form->get_fd_message(
'no_payment');
2261 $form->add_fld_error(
'payment_method', $checkoutError);
2263 $checkoutError =
$form->get_fd_message(
'select_payment');
2264 $form->add_fld_error(
'payment_method', $checkoutError);
2265 } elseif (!$acceptLegal || !$acceptWithdrawal) {
2266 $checkoutError =
$form->get_fd_message(
'confirm_legal');
2267 if (!$acceptLegal) {
2268 $form->add_fld_error(
2270 $form->get_fd_message(
'legal_field_error')
2273 if (!$acceptWithdrawal) {
2274 $form->add_fld_error(
2275 'accept_withdrawal',
2276 $form->get_fd_message(
'withdrawal_field_error')
2281 if ($checkoutError !==
'') {
2282 $form->_msg_error = $checkoutError;
2285 $requestId = $checkoutRequestId;
2286 $existingOrder = $this->checkoutRequestOrder($requestId);
2287 if (is_array($existingOrder)) {
2288 return $this->continueCheckoutOrder($existingOrder, $paymentMethod);
2291 [$legalSnapshot, $withdrawalSnapshot] = $this->legalSnapshotsForOrder();
2292 $order = $this->repo()->createOrderFromItems(
2294 $this->activeChannel(),
2299 in_array($paymentMethod, array(
'paypal',
'amazon_pay'),
true) ?
'created' :
'open',
2311 $this->rememberCheckoutRequest($requestId,
$order);
2312 return $this->continueCheckoutOrder(
$order, $paymentMethod);
2314 }
catch (\Throwable $e) {
2315 dbx()->sys_msg(
'error',
'dbxShop',
'checkout',
'checkout failed', $e->getMessage());
2325 $form->get_fd_message(
'page_title'),
2326 $form->get_fd_message(
'page_subtitle'),
2333 $this->ensureSeed();
2342 private function providerReturnOrder(
string $provider,
string $reference,
string $orderNo): ?array {
2345 if (!is_array(
$order))
return null;
2346 if ($orderNo !==
'' && !hash_equals((
string)(
$order[
'order_no'] ??
''), $orderNo)) {
2352 private function rememberProviderOrder(array
$order,
bool $clearCart =
true): void {
2353 $this->startSession();
2354 if ($clearCart)
$_SESSION[
'dbxShop_cart'] = array();
2355 $_SESSION[
'dbxShop_last_order_no'] = (string)(
$order[
'order_no'] ??
'');
2359 $this->ensureSeed();
2360 $paypalOrderId = (string)
dbx()->get_modul_var(
'token',
'',
'parameter');
2361 $orderNo = (string)
dbx()->get_modul_var(
'order_no',
'',
'parameter');
2362 $order = $this->providerReturnOrder(
'paypal', $paypalOrderId, $orderNo);
2363 if (!
$order || $paypalOrderId ===
'') {
2364 return $this->page(
'PayPal',
'Rueckkehr konnte nicht zugeordnet werden.',
'<div class="alert alert-danger m-3">Die PayPal-Rueckkehr konnte keiner Bestellung zugeordnet werden.</div>',
'checkout');
2367 if (in_array((
string)(
$order[
'payment_status'] ??
''), array(
'completed',
'paid'),
true)) {
2368 $this->rememberProviderOrder(
$order);
2369 $body =
'<div class="alert alert-success m-3"><strong>Zahlung bereits abgeschlossen.</strong><br>Bestellung ' . $this->h(
$order[
'order_no'] ??
'') .
' ist bezahlt.</div>';
2370 return $this->page(
'Danke',
'PayPal-Zahlung wurde bestaetigt.',
$body,
'orders');
2372 if (!$this->repo()->claimOrderPayment((
int)
$order[
'id'],
'paypal', $paypalOrderId)) {
2373 $fresh = $this->repo()->orderByPaymentReference(
'paypal', $paypalOrderId) ?:
$order;
2374 $body =
'<div class="alert alert-info m-3"><strong>Zahlung wird bereits verarbeitet.</strong><br>Bestellung ' . $this->h($fresh[
'order_no'] ??
'') .
' wird nicht doppelt belastet.</div>';
2375 return $this->page(
'PayPal',
'Zahlungsstatus wird verarbeitet.',
$body,
'orders');
2379 $capture = $this->paypal()->capture($paypalOrderId);
2380 $this->paypal()->validateCapture($capture,
$order, $paypalOrderId);
2381 if (!$this->repo()->updateOrderPayment((
int)
$order[
'id'],
'paypal',
'completed', $paypalOrderId, $capture)) {
2382 throw new \RuntimeException(
'PayPal-Zahlungsstatus konnte nicht atomar gespeichert werden.');
2384 $freshOrder = $this->repo()->orderById((
int)
$order[
'id']) ?:
$order;
2385 $this->sendOrderMails($freshOrder);
2386 $this->rememberProviderOrder($freshOrder);
2387 $body =
'<div class="alert alert-success m-3"><strong>Zahlung abgeschlossen.</strong><br>Bestellung ' . $this->h(
$order[
'order_no'] ??
'') .
' wurde bezahlt.</div>';
2388 return $this->page(
'Danke',
'PayPal-Zahlung wurde bestaetigt.',
$body,
'orders');
2389 }
catch (\Throwable $e) {
2390 $this->repo()->updateOrderPayment((
int)
$order[
'id'],
'paypal',
'failed', $paypalOrderId, array(
'error' => $e->getMessage()));
2391 return $this->page(
'PayPal',
'Zahlung konnte nicht bestaetigt werden.',
'<div class="alert alert-danger m-3">' . $this->h($e->getMessage()) .
'</div>',
'checkout');
2397 'PayPal abgebrochen',
2398 'Die Zahlung wurde nicht ausgefuehrt.',
2399 '<div class="alert alert-info m-3">Die PayPal-Seite wurde verlassen. Der Warenkorb bleibt erhalten; der serverseitige Zahlungsstatus wird nicht allein aufgrund dieses Browseraufrufs veraendert.</div>',
2405 $this->ensureSeed();
2406 $checkoutSessionId = (string)
dbx()->get_modul_var(
'checkoutSessionId',
'',
'parameter');
2407 if ($checkoutSessionId ===
'') {
2408 $checkoutSessionId = (string)
dbx()->get_modul_var(
'amazonCheckoutSessionId',
'',
'parameter');
2410 $orderNo = (string)
dbx()->get_modul_var(
'order_no',
'',
'parameter');
2411 $order = $this->providerReturnOrder(
'amazon_pay', $checkoutSessionId, $orderNo);
2412 if (!
$order || $checkoutSessionId ===
'') {
2413 return $this->page(
'Amazon Pay',
'Rueckkehr konnte nicht zugeordnet werden.',
'<div class="alert alert-danger m-3">Die Amazon-Pay-Rueckkehr konnte keiner Bestellung zugeordnet werden.</div>',
'checkout');
2416 if (in_array((
string)(
$order[
'payment_status'] ??
''), array(
'completed',
'paid',
'pending'),
true)) {
2417 $this->rememberProviderOrder(
$order);
2418 $body =
'<div class="alert alert-success m-3"><strong>Amazon-Pay-Zahlung bereits verarbeitet.</strong><br>Bestellung ' . $this->h(
$order[
'order_no'] ??
'') .
' besitzt bereits einen gueltigen Providerstatus.</div>';
2419 return $this->page(
'Danke',
'Amazon-Pay-Zahlung wurde verarbeitet.',
$body,
'orders');
2421 if (!$this->repo()->claimOrderPayment((
int)
$order[
'id'],
'amazon_pay', $checkoutSessionId)) {
2422 $fresh = $this->repo()->orderByPaymentReference(
'amazon_pay', $checkoutSessionId) ?:
$order;
2423 $body =
'<div class="alert alert-info m-3"><strong>Zahlung wird bereits verarbeitet.</strong><br>Bestellung ' . $this->h($fresh[
'order_no'] ??
'') .
' wird nicht doppelt abgeschlossen.</div>';
2424 return $this->page(
'Amazon Pay',
'Zahlungsstatus wird verarbeitet.',
$body,
'orders');
2428 $result = $this->amazonPay()->completeCheckoutSession($checkoutSessionId,
$order);
2429 $paymentStatus = $this->amazonPay()->validateCompletion(
$result,
$order, $checkoutSessionId);
2430 if (!$this->repo()->updateOrderPayment((
int)
$order[
'id'],
'amazon_pay', $paymentStatus, $checkoutSessionId,
$result)) {
2431 throw new \RuntimeException(
'Amazon-Pay-Status konnte nicht atomar gespeichert werden.');
2433 $freshOrder = $this->repo()->orderById((
int)
$order[
'id']) ?:
$order;
2434 if ($paymentStatus ===
'completed') {
2435 $this->sendOrderMails($freshOrder);
2437 $this->rememberProviderOrder($freshOrder);
2438 $body =
'<div class="alert alert-success m-3"><strong>Amazon-Pay-Zahlung verarbeitet.</strong><br>Bestellung ' . $this->h(
$order[
'order_no'] ??
'') .
' wurde aktualisiert.</div>';
2439 return $this->page(
'Danke',
'Amazon-Pay-Zahlung wurde bestaetigt.',
$body,
'orders');
2440 }
catch (\Throwable $e) {
2441 $this->repo()->updateOrderPayment((
int)
$order[
'id'],
'amazon_pay',
'failed', $checkoutSessionId, array(
'error' => $e->getMessage()));
2442 return $this->page(
'Amazon Pay',
'Zahlung konnte nicht bestaetigt werden.',
'<div class="alert alert-danger m-3">' . $this->h($e->getMessage()) .
'</div>',
'checkout');
2448 'Amazon Pay abgebrochen',
2449 'Die Zahlung wurde nicht ausgefuehrt.',
2450 '<div class="alert alert-info m-3">Die Amazon-Pay-Seite wurde verlassen. Der Warenkorb bleibt erhalten; der serverseitige Zahlungsstatus wird nicht allein aufgrund dieses Browseraufrufs veraendert.</div>',
2456 $this->ensureSeed();
2457 $this->startSession();
2458 $texts = $this->texts(
'dbxShop|shop-orders');
2461 $lastOrderNo = (string)(
$_SESSION[
'dbxShop_last_order_no'] ??
'');
2462 if ($lastOrderNo !==
'') {
2463 $order = $this->repo()->orderByNo($lastOrderNo);
2465 $cards .= $this->publicOrderCard(
$order);
2466 $seen[(string)(
$order[
'order_no'] ??
'')] =
true;
2470 $uid = function_exists(
'dbx') ? (int)
dbx()->user() : 0;
2471 foreach ($this->repo()->ordersByUid($uid, 25) as
$order) {
2472 $orderNo = (string)(
$order[
'order_no'] ??
'');
2473 if ($orderNo !==
'' && isset($seen[$orderNo])) {
2476 $cards .= $this->publicOrderCard(
$order);
2479 if ($cards ===
'') {
2480 $cards = $this->placeholder(
2481 $texts->get_fd_message(
'empty_title'),
2482 $texts->get_fd_message(
'empty_message')
2485 $cards =
'<section class="dbx-shop-public-orders">' . $cards .
'</section>';
2489 $texts->get_fd_message(
'page_title'),
2490 $texts->get_fd_message(
'page_subtitle'),
2496 private function orderIsPublicAccessible(array
$order): bool {
2497 $this->startSession();
2498 $orderNo = (string)(
$order[
'order_no'] ??
'');
2499 if ($orderNo !==
'' && $orderNo === (
string)(
$_SESSION[
'dbxShop_last_order_no'] ??
'')) {
2502 $uid = function_exists(
'dbx') ? (int)
dbx()->user() : 0;
2503 return $uid > 0 && (int)(
$order[
'uid'] ?? 0) === $uid;
2507 $this->ensureSeed();
2508 $this->startSession();
2509 $orderNo = (string)
dbx()->get_modul_var(
'order_no',
'',
'parameter');
2510 $order = $orderNo !==
'' ? $this->repo()->orderByNo($orderNo) :
null;
2511 if (!is_array(
$order) || !$this->orderIsPublicAccessible(
$order)) {
2512 return $this->page(
'Rechnung',
'Zugriff nicht moeglich.',
'<div class="alert alert-warning m-3">Die Rechnung wurde nicht gefunden oder ist fuer diesen Benutzer nicht freigegeben.</div>',
'orders');
2514 $order = $this->repo()->ensureOrderInvoicePdf((
int)
$order[
'id']);
2516 return $this->page(
'Rechnung',
'PDF konnte nicht erzeugt werden.',
'<div class="alert alert-danger m-3">Die Rechnungsdatei konnte nicht erzeugt werden.</div>',
'orders');
2518 $file = $this->repo()->invoicePdfAbsolutePath(
$order);
2520 return $this->page(
'Rechnung',
'PDF konnte nicht geladen werden.',
'<div class="alert alert-danger m-3">Die Rechnungsdatei ist nicht verfuegbar.</div>',
'orders');
2522 if (!headers_sent()) {
2523 header(
'Content-Type: application/pdf');
2524 header(
'Content-Disposition: inline; filename="' . basename(
$file) .
'"');
2525 header(
'Content-Length: ' . filesize(
$file));
2531 private function jsonResponse(array $data,
int $status = 200): string {
2532 if (!headers_sent()) {
2533 http_response_code($status);
2534 header(
'Content-Type: application/json; charset=utf-8');
2536 return json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?:
'{}';
2540 $channelKey = (string)
dbx()->get_modul_var(
'channel',
'',
'parameter');
2541 $raw = (string)file_get_contents(
'php://input');
2542 $payload = json_decode($raw,
true);
2543 if (!is_array($payload)) {
2548 $channel = $this->repo()->channelByKey($channelKey);
2550 return $this->jsonResponse(array(
'ok' =>
false,
'message' =>
'Channel nicht gefunden.'), 404);
2552 if ((
int)($channel[
'active'] ?? 0) !== 1 || (
int)($channel[
'order_import_enabled'] ?? 0) !== 1) {
2553 return $this->jsonResponse(array(
'ok' =>
false,
'message' =>
'Order-Import fuer diesen Channel ist nicht aktiv.'), 403);
2556 $secret = trim((
string)($channel[
'webhook_secret'] ??
''));
2557 if ($secret ===
'') {
2560 return $this->jsonResponse(array(
2562 'message' =>
'Webhook-Authentifizierung ist nicht konfiguriert.',
2567 $given = trim((
string)(
2569 ??
$_SERVER[
'HTTP_X_CHANNEL_SECRET']
2571 ?? $payload[
'secret']
2574 if ($given ===
'' || !hash_equals($secret, $given)) {
2575 return $this->jsonResponse(array(
'ok' =>
false,
'message' =>
'Webhook-Secret ungueltig.'), 403);
2578 $connector =
dbx()->get_include_obj(
'dbxShopChannelConnector',
'dbxShop');
2579 if (is_object($connector) && method_exists($connector,
'normalizeWebhookPayload')) {
2580 $payload = (array)$connector->normalizeWebhookPayload($channel, $payload);
2583 $order = $this->repo()->importChannelOrder($channelKey, $payload);
2584 return $this->jsonResponse(array(
2586 'order_no' => (
string)(
$order[
'order_no'] ??
''),
2587 'channel' => $channelKey,
2589 }
catch (\Throwable $e) {
2590 return $this->jsonResponse(array(
'ok' =>
false,
'message' => $e->getMessage()), 400);
2595 return $this->renderCmsShopPage(
2598 'AGB, Anbieterkennzeichnung, Zahlung, Versand und Datenschutz-Hinweise.',
2604 $form =
dbx()->get_system_obj(
'dbxForm');
2605 $form->init(
'shop-withdrawal-form',
'shop-withdrawal-form');
2606 $form->_fd =
'dbxShop|withdrawal';
2607 $form->load_fd_messages();
2609 $cid = (int)(
$pages[
'withdrawal'] ?? 0);
2612 $renderer =
dbx()->get_include_obj(
'dbxContentRenderer',
'dbxContent');
2613 $body = is_object(
$renderer) ? (string)
$renderer->renderStatic($cid, array(
'template' =>
'c-body1-footer')) :
'';
2615 if (trim(
$body) ===
'') {
2616 $body = $this->placeholder(
2617 $form->get_fd_message(
'page_title'),
2618 $form->get_fd_message(
'empty_content')
2622 $form->get_fd_message(
'page_title'),
2623 $form->get_fd_message(
'page_subtitle'),
2624 '<div class="dbx-shop-cms-page">' .
$body .
'</div>' . $this->withdrawalFormHtml(
$form),