functions.php 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717
  1. <?php
  2. require_once __DIR__ . '/../config.php';
  3. /**
  4. * Read JSON file and return decoded data
  5. */
  6. function readJsonFile($file) {
  7. if (!file_exists($file)) {
  8. return [];
  9. }
  10. $content = file_get_contents($file);
  11. if (empty($content)) {
  12. return [];
  13. }
  14. $data = json_decode($content, true);
  15. return $data ? $data : [];
  16. }
  17. /**
  18. * Write data to JSON file
  19. */
  20. function writeJsonFile($file, $data) {
  21. $dir = dirname($file);
  22. if (!is_dir($dir)) {
  23. mkdir($dir, 0755, true);
  24. }
  25. file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
  26. }
  27. /**
  28. * Normalize admin username input.
  29. */
  30. function normalizeAdminUsername($username) {
  31. return trim((string)$username);
  32. }
  33. /**
  34. * Normalize admin description input.
  35. */
  36. function normalizeAdminDescription($description) {
  37. return trim((string)$description);
  38. }
  39. /**
  40. * Normalize admin email input.
  41. */
  42. function normalizeAdminEmail($email) {
  43. return strtolower(trim((string)$email));
  44. }
  45. /**
  46. * Validate admin username format.
  47. */
  48. function isValidAdminUsername($username) {
  49. $username = normalizeAdminUsername($username);
  50. return preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{2,49}$/', $username) === 1;
  51. }
  52. /**
  53. * Validate admin description.
  54. */
  55. function isValidAdminDescription($description) {
  56. $description = normalizeAdminDescription($description);
  57. if ($description === '') {
  58. return false;
  59. }
  60. $length = function_exists('mb_strlen') ? mb_strlen($description) : strlen($description);
  61. return $length <= 120;
  62. }
  63. /**
  64. * Validate admin email.
  65. */
  66. function isValidAdminEmail($email) {
  67. $email = normalizeAdminEmail($email);
  68. return $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
  69. }
  70. /**
  71. * Get default description for an admin account.
  72. */
  73. function getDefaultAdminDescription($username) {
  74. return 'Admin';
  75. }
  76. /**
  77. * Get fallback admin email address from config.
  78. */
  79. function getDefaultAdminEmail() {
  80. if (!defined('ADMIN_EMAIL') || !is_string(ADMIN_EMAIL)) {
  81. return '';
  82. }
  83. $fallbackEmail = normalizeAdminEmail(ADMIN_EMAIL);
  84. if (!isValidAdminEmail($fallbackEmail)) {
  85. return '';
  86. }
  87. return $fallbackEmail;
  88. }
  89. /**
  90. * Get full admin account records from JSON store.
  91. */
  92. function getAdminAccounts() {
  93. $data = readJsonFile(ADMINS_FILE);
  94. $accounts = [];
  95. if (isset($data['admins']) && is_array($data['admins'])) {
  96. foreach ($data['admins'] as $username => $record) {
  97. $normalizedUsername = normalizeAdminUsername($username);
  98. if ($normalizedUsername === '') {
  99. continue;
  100. }
  101. $hash = '';
  102. $description = '';
  103. $email = getDefaultAdminEmail();
  104. if (is_string($record)) {
  105. $hash = $record;
  106. $description = getDefaultAdminDescription($normalizedUsername);
  107. } elseif (is_array($record)) {
  108. $hash = isset($record['password_hash']) && is_string($record['password_hash']) ? $record['password_hash'] : '';
  109. $description = isset($record['description']) ? normalizeAdminDescription($record['description']) : getDefaultAdminDescription($normalizedUsername);
  110. $email = isset($record['email']) ? normalizeAdminEmail($record['email']) : getDefaultAdminEmail();
  111. }
  112. if ($hash === '') {
  113. continue;
  114. }
  115. if (!isValidAdminDescription($description)) {
  116. $description = getDefaultAdminDescription($normalizedUsername);
  117. }
  118. if (!isValidAdminEmail($email)) {
  119. $email = getDefaultAdminEmail();
  120. }
  121. $accounts[$normalizedUsername] = [
  122. 'password_hash' => $hash,
  123. 'description' => $description,
  124. 'email' => $email
  125. ];
  126. }
  127. }
  128. return $accounts;
  129. }
  130. /**
  131. * Get admin users for authentication (username => password hash).
  132. */
  133. function getAdminUsers() {
  134. $accounts = getAdminAccounts();
  135. $admins = [];
  136. foreach ($accounts as $username => $account) {
  137. if (!isset($account['password_hash']) || !is_string($account['password_hash']) || $account['password_hash'] === '') {
  138. continue;
  139. }
  140. $admins[$username] = $account['password_hash'];
  141. }
  142. return $admins;
  143. }
  144. /**
  145. * Save full admin accounts to JSON store.
  146. */
  147. function saveAdminAccounts($accounts) {
  148. $sanitizedAccounts = [];
  149. foreach ($accounts as $username => $account) {
  150. $normalizedUsername = normalizeAdminUsername($username);
  151. if ($normalizedUsername === '' || !is_array($account)) {
  152. continue;
  153. }
  154. $hash = isset($account['password_hash']) && is_string($account['password_hash']) ? $account['password_hash'] : '';
  155. if ($hash === '') {
  156. continue;
  157. }
  158. $description = isset($account['description']) ? normalizeAdminDescription($account['description']) : getDefaultAdminDescription($normalizedUsername);
  159. if (!isValidAdminDescription($description)) {
  160. $description = getDefaultAdminDescription($normalizedUsername);
  161. }
  162. $email = isset($account['email']) ? normalizeAdminEmail($account['email']) : getDefaultAdminEmail();
  163. if (!isValidAdminEmail($email)) {
  164. $email = getDefaultAdminEmail();
  165. }
  166. $sanitizedAccounts[$normalizedUsername] = [
  167. 'password_hash' => $hash,
  168. 'description' => $description,
  169. 'email' => $email
  170. ];
  171. }
  172. ksort($sanitizedAccounts);
  173. writeJsonFile(ADMINS_FILE, ['admins' => $sanitizedAccounts]);
  174. }
  175. /**
  176. * Save admin users to JSON store (username => password hash).
  177. */
  178. function saveAdminUsers($admins) {
  179. $existingAccounts = getAdminAccounts();
  180. $normalizedAccounts = [];
  181. foreach ($admins as $username => $hash) {
  182. $normalizedUsername = normalizeAdminUsername($username);
  183. if ($normalizedUsername === '' || !is_string($hash) || $hash === '') {
  184. continue;
  185. }
  186. $description = isset($existingAccounts[$normalizedUsername]['description'])
  187. ? normalizeAdminDescription($existingAccounts[$normalizedUsername]['description'])
  188. : getDefaultAdminDescription($normalizedUsername);
  189. if (!isValidAdminDescription($description)) {
  190. $description = getDefaultAdminDescription($normalizedUsername);
  191. }
  192. $email = isset($existingAccounts[$normalizedUsername]['email'])
  193. ? normalizeAdminEmail($existingAccounts[$normalizedUsername]['email'])
  194. : getDefaultAdminEmail();
  195. if (!isValidAdminEmail($email)) {
  196. $email = getDefaultAdminEmail();
  197. }
  198. $normalizedAccounts[$normalizedUsername] = [
  199. 'password_hash' => $hash,
  200. 'description' => $description,
  201. 'email' => $email
  202. ];
  203. }
  204. saveAdminAccounts($normalizedAccounts);
  205. }
  206. /**
  207. * Get default category records.
  208. */
  209. function getDefaultCategories() {
  210. return [
  211. ['id' => 'apparel', 'label' => 'Bekleidung'],
  212. ['id' => 'merch', 'label' => 'Merchandise']
  213. ];
  214. }
  215. /**
  216. * Normalize category id input to a stable slug.
  217. */
  218. function normalizeCategoryId($id) {
  219. $id = trim((string) $id);
  220. if ($id === '') {
  221. return '';
  222. }
  223. if (function_exists('iconv')) {
  224. $converted = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $id);
  225. if (is_string($converted) && $converted !== '') {
  226. $id = $converted;
  227. }
  228. }
  229. $id = strtolower($id);
  230. $id = preg_replace('/[^a-z0-9]+/', '-', $id);
  231. $id = trim((string) $id, '-');
  232. return $id;
  233. }
  234. /**
  235. * Normalize category label input.
  236. */
  237. function normalizeCategoryLabel($label) {
  238. return trim((string) $label);
  239. }
  240. /**
  241. * Validate category label.
  242. */
  243. function isValidCategoryLabel($label) {
  244. $label = normalizeCategoryLabel($label);
  245. if ($label === '') {
  246. return false;
  247. }
  248. $length = function_exists('mb_strlen') ? mb_strlen($label) : strlen($label);
  249. return $length <= 80;
  250. }
  251. /**
  252. * Normalize category records from storage.
  253. */
  254. function normalizeCategories($categories) {
  255. $normalized = [];
  256. if (!is_array($categories)) {
  257. $categories = [];
  258. }
  259. foreach ($categories as $category) {
  260. if (!is_array($category)) {
  261. continue;
  262. }
  263. $id = normalizeCategoryId($category['id'] ?? '');
  264. $label = normalizeCategoryLabel($category['label'] ?? '');
  265. if ($id === '' || !isValidCategoryLabel($label)) {
  266. continue;
  267. }
  268. $normalized[$id] = [
  269. 'id' => $id,
  270. 'label' => $label
  271. ];
  272. }
  273. if (empty($normalized)) {
  274. foreach (getDefaultCategories() as $category) {
  275. $normalized[$category['id']] = $category;
  276. }
  277. }
  278. uasort($normalized, function ($left, $right) {
  279. return strcasecmp($left['label'], $right['label']);
  280. });
  281. return array_values($normalized);
  282. }
  283. /**
  284. * Get all categories.
  285. */
  286. function getCategories() {
  287. $data = readJsonFile(CATEGORIES_FILE);
  288. $categories = isset($data['categories']) ? $data['categories'] : [];
  289. return normalizeCategories($categories);
  290. }
  291. /**
  292. * Get category by id.
  293. */
  294. function getCategoryById($categoryId) {
  295. $categoryId = normalizeCategoryId($categoryId);
  296. foreach (getCategories() as $category) {
  297. if ($category['id'] === $categoryId) {
  298. return $category;
  299. }
  300. }
  301. return null;
  302. }
  303. /**
  304. * Get category label by id with fallback to raw id.
  305. */
  306. function getCategoryLabel($categoryId) {
  307. $category = getCategoryById($categoryId);
  308. if ($category !== null) {
  309. return $category['label'];
  310. }
  311. $categoryId = trim((string) $categoryId);
  312. return $categoryId !== '' ? $categoryId : 'Unbekannt';
  313. }
  314. /**
  315. * Get category labels by ids.
  316. */
  317. function getCategoryLabels($categoryIds) {
  318. $labels = [];
  319. foreach (normalizeProductCategoryIds($categoryIds) as $categoryId) {
  320. $labels[] = getCategoryLabel($categoryId);
  321. }
  322. return $labels;
  323. }
  324. /**
  325. * Save categories.
  326. */
  327. function saveCategories($categories) {
  328. writeJsonFile(CATEGORIES_FILE, ['categories' => normalizeCategories($categories)]);
  329. }
  330. /**
  331. * Generate a unique category id from a label.
  332. */
  333. function generateCategoryIdFromLabel($label, $existingCategories = []) {
  334. $baseId = normalizeCategoryId($label);
  335. if ($baseId === '') {
  336. $baseId = 'category';
  337. }
  338. $usedIds = [];
  339. foreach (normalizeCategories($existingCategories) as $category) {
  340. $usedIds[$category['id']] = true;
  341. }
  342. $candidate = $baseId;
  343. $counter = 2;
  344. while (isset($usedIds[$candidate])) {
  345. $candidate = $baseId . '-' . $counter;
  346. $counter++;
  347. }
  348. return $candidate;
  349. }
  350. /**
  351. * Check whether any product uses a category id.
  352. */
  353. function isCategoryInUse($categoryId) {
  354. $categoryId = normalizeCategoryId($categoryId);
  355. foreach (getProducts() as $product) {
  356. if (productHasCategory($product, $categoryId)) {
  357. return true;
  358. }
  359. }
  360. return false;
  361. }
  362. /**
  363. * Normalize product category ids from legacy or current storage.
  364. */
  365. function normalizeProductCategoryIds($categoryValue) {
  366. if (is_array($categoryValue)) {
  367. $rawCategoryIds = $categoryValue;
  368. } elseif ($categoryValue === null || $categoryValue === '') {
  369. $rawCategoryIds = [];
  370. } else {
  371. $rawCategoryIds = [$categoryValue];
  372. }
  373. $normalized = [];
  374. foreach ($rawCategoryIds as $categoryId) {
  375. $categoryId = normalizeCategoryId($categoryId);
  376. if ($categoryId === '') {
  377. continue;
  378. }
  379. $normalized[$categoryId] = $categoryId;
  380. }
  381. return array_values($normalized);
  382. }
  383. /**
  384. * Get normalized category ids for a product.
  385. */
  386. function getProductCategoryIds($product) {
  387. if (isset($product['categories'])) {
  388. return normalizeProductCategoryIds($product['categories']);
  389. }
  390. return normalizeProductCategoryIds($product['category'] ?? []);
  391. }
  392. /**
  393. * Determine whether a product is assigned to a category.
  394. */
  395. function productHasCategory($product, $categoryId) {
  396. $categoryId = normalizeCategoryId($categoryId);
  397. if ($categoryId === '') {
  398. return false;
  399. }
  400. return in_array($categoryId, getProductCategoryIds($product), true);
  401. }
  402. /**
  403. * Parse product sizes into a normalized array.
  404. */
  405. function getProductSizes($product) {
  406. if (isset($product['sizes']) && is_array($product['sizes'])) {
  407. $sizes = $product['sizes'];
  408. } elseif (isset($product['sizes']) && is_string($product['sizes'])) {
  409. $sizes = explode(',', $product['sizes']);
  410. } else {
  411. $sizes = [];
  412. }
  413. $normalized = [];
  414. foreach ($sizes as $size) {
  415. $size = trim((string) $size);
  416. if ($size === '') {
  417. continue;
  418. }
  419. $normalized[$size] = $size;
  420. }
  421. return array_values($normalized);
  422. }
  423. /**
  424. * Determine whether a product uses size-based stock.
  425. */
  426. function productUsesSizeStock($product) {
  427. return !empty(getProductSizes($product));
  428. }
  429. /**
  430. * Normalize a single product record for backwards compatibility.
  431. */
  432. function normalizeProductRecord($product, $defaultCategoryId = '') {
  433. if (!is_array($product)) {
  434. return null;
  435. }
  436. $product['id'] = isset($product['id']) ? (int) $product['id'] : 0;
  437. $product['name'] = isset($product['name']) ? trim((string) $product['name']) : '';
  438. $product['description'] = isset($product['description']) ? trim((string) $product['description']) : '';
  439. $product['price'] = isset($product['price']) ? (float) $product['price'] : 0.0;
  440. $product['image'] = isset($product['image']) ? trim((string) $product['image']) : '';
  441. $categoryIds = getProductCategoryIds($product);
  442. if (empty($categoryIds) && $defaultCategoryId !== '') {
  443. $categoryIds = [$defaultCategoryId];
  444. }
  445. $product['categories'] = $categoryIds;
  446. unset($product['category']);
  447. $sizes = getProductSizes($product);
  448. $stockBySize = isset($product['stock_by_size']) && is_array($product['stock_by_size']) ? $product['stock_by_size'] : [];
  449. if (empty($sizes) && array_key_exists('stock', $product)) {
  450. $sizes = ['Standard'];
  451. $stockBySize = ['Standard' => (int) $product['stock']];
  452. }
  453. if (!empty($sizes)) {
  454. $normalizedStockBySize = [];
  455. foreach ($sizes as $size) {
  456. $normalizedStockBySize[$size] = isset($stockBySize[$size]) ? max(0, (int) $stockBySize[$size]) : 0;
  457. }
  458. $product['sizes'] = implode(',', $sizes);
  459. $product['stock_by_size'] = $normalizedStockBySize;
  460. unset($product['stock']);
  461. } else {
  462. $product['stock'] = isset($product['stock']) ? max(0, (int) $product['stock']) : 0;
  463. unset($product['stock_by_size']);
  464. unset($product['sizes']);
  465. }
  466. return $product;
  467. }
  468. /**
  469. * Get all products
  470. */
  471. function getProducts() {
  472. $data = readJsonFile(PRODUCTS_FILE);
  473. $rawProducts = isset($data['products']) && is_array($data['products']) ? $data['products'] : [];
  474. $categories = getCategories();
  475. $defaultCategoryId = !empty($categories) ? $categories[0]['id'] : 'apparel';
  476. $products = [];
  477. foreach ($rawProducts as $product) {
  478. $normalizedProduct = normalizeProductRecord($product, $defaultCategoryId);
  479. if ($normalizedProduct === null) {
  480. continue;
  481. }
  482. $products[] = $normalizedProduct;
  483. }
  484. return $products;
  485. }
  486. /**
  487. * Get product by ID
  488. */
  489. function getProductById($id) {
  490. $products = getProducts();
  491. foreach ($products as $product) {
  492. if ($product['id'] == $id) {
  493. return $product;
  494. }
  495. }
  496. return null;
  497. }
  498. /**
  499. * Save products
  500. */
  501. function saveProducts($products) {
  502. $categories = getCategories();
  503. $defaultCategoryId = !empty($categories) ? $categories[0]['id'] : 'apparel';
  504. $normalizedProducts = [];
  505. foreach ($products as $product) {
  506. $normalizedProduct = normalizeProductRecord($product, $defaultCategoryId);
  507. if ($normalizedProduct === null) {
  508. continue;
  509. }
  510. $normalizedProducts[] = $normalizedProduct;
  511. }
  512. $data = ['products' => $normalizedProducts];
  513. writeJsonFile(PRODUCTS_FILE, $data);
  514. }
  515. /**
  516. * Resolve FAQ file path and force storage inside DATA_DIR.
  517. */
  518. function getFaqFilePath(): string {
  519. $dataDir = defined('DATA_DIR') && is_string(DATA_DIR) ? DATA_DIR : (dirname(__DIR__) . '/data/');
  520. $defaultPath = rtrim($dataDir, '/\\') . '/faq.json';
  521. if (!defined('FAQ_FILE') || !is_string(FAQ_FILE) || FAQ_FILE === '') {
  522. return $defaultPath;
  523. }
  524. $configuredPath = FAQ_FILE;
  525. $normalizedDataDir = str_replace('\\', '/', rtrim($dataDir, '/\\')) . '/';
  526. $normalizedConfigured = str_replace('\\', '/', $configuredPath);
  527. if (strpos($normalizedConfigured, $normalizedDataDir) !== 0) {
  528. return $defaultPath;
  529. }
  530. return $configuredPath;
  531. }
  532. /**
  533. * Get FAQ markdown content from JSON store.
  534. */
  535. function getFaqContent(): string {
  536. $defaultContent = "# FAQ\n\nHier kann der FAQ-Inhalt im Admin-Bereich bearbeitet werden.";
  537. $data = readJsonFile(getFaqFilePath());
  538. if (!is_array($data)) {
  539. return $defaultContent;
  540. }
  541. if (!isset($data['content']) || !is_string($data['content'])) {
  542. return $defaultContent;
  543. }
  544. return $data['content'];
  545. }
  546. /**
  547. * Save FAQ markdown content to JSON store.
  548. */
  549. function saveFaqContent(string $markdown): void {
  550. writeJsonFile(getFaqFilePath(), ['content' => (string) $markdown]);
  551. }
  552. /**
  553. * Render inline markdown safely (bold + italic).
  554. */
  555. function renderFaqInlineMarkdown(string $text): string {
  556. $escaped = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
  557. // Apply markdown after escaping so raw HTML is never executed.
  558. $escaped = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $escaped);
  559. $escaped = preg_replace('/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/s', '<em>$1</em>', $escaped);
  560. return $escaped;
  561. }
  562. /**
  563. * Render a minimal safe markdown subset for FAQ content.
  564. */
  565. function renderFaqMarkdown(string $markdown): string {
  566. $normalized = str_replace(["\r\n", "\r"], "\n", $markdown);
  567. $lines = explode("\n", $normalized);
  568. $htmlParts = [];
  569. $paragraphLines = [];
  570. $listType = '';
  571. $flushParagraph = function () use (&$paragraphLines, &$htmlParts): void {
  572. if (empty($paragraphLines)) {
  573. return;
  574. }
  575. $renderedLines = [];
  576. foreach ($paragraphLines as $line) {
  577. $renderedLines[] = renderFaqInlineMarkdown($line);
  578. }
  579. $htmlParts[] = '<p>' . implode("<br>\n", $renderedLines) . '</p>';
  580. $paragraphLines = [];
  581. };
  582. $closeList = function () use (&$listType, &$htmlParts): void {
  583. if ($listType === '') {
  584. return;
  585. }
  586. $htmlParts[] = '</' . $listType . '>';
  587. $listType = '';
  588. };
  589. foreach ($lines as $line) {
  590. $line = rtrim($line);
  591. $trimmed = trim($line);
  592. if ($trimmed === '') {
  593. $flushParagraph();
  594. $closeList();
  595. continue;
  596. }
  597. if (preg_match('/^(#{1,3})\s+(.+)$/', $trimmed, $matches) === 1) {
  598. $flushParagraph();
  599. $closeList();
  600. $level = strlen($matches[1]);
  601. $htmlParts[] = '<h' . $level . '>' . renderFaqInlineMarkdown($matches[2]) . '</h' . $level . '>';
  602. continue;
  603. }
  604. if (preg_match('/^\s*[-*]\s+(.+)$/', $line, $matches) === 1) {
  605. $flushParagraph();
  606. if ($listType !== 'ul') {
  607. $closeList();
  608. $listType = 'ul';
  609. $htmlParts[] = '<ul>';
  610. }
  611. $htmlParts[] = '<li>' . renderFaqInlineMarkdown($matches[1]) . '</li>';
  612. continue;
  613. }
  614. if (preg_match('/^\s*\d+\.\s+(.+)$/', $line, $matches) === 1) {
  615. $flushParagraph();
  616. if ($listType !== 'ol') {
  617. $closeList();
  618. $listType = 'ol';
  619. $htmlParts[] = '<ol>';
  620. }
  621. $htmlParts[] = '<li>' . renderFaqInlineMarkdown($matches[1]) . '</li>';
  622. continue;
  623. }
  624. $closeList();
  625. $paragraphLines[] = $trimmed;
  626. }
  627. $flushParagraph();
  628. $closeList();
  629. if (empty($htmlParts)) {
  630. return '<p>Keine FAQ-Inhalte vorhanden.</p>';
  631. }
  632. return implode("\n", $htmlParts);
  633. }
  634. /**
  635. * Get all reservations
  636. */
  637. function getReservations() {
  638. $data = readJsonFile(RESERVATIONS_FILE);
  639. return isset($data['reservations']) ? $data['reservations'] : [];
  640. }
  641. /**
  642. * Get reservation by order number
  643. */
  644. function getReservationByOrderNumber($orderNumber) {
  645. $reservations = getReservations();
  646. foreach ($reservations as $reservation) {
  647. if (isset($reservation['id']) && $reservation['id'] === $orderNumber && !isReservationHidden($reservation)) {
  648. return $reservation;
  649. }
  650. }
  651. return null;
  652. }
  653. /**
  654. * Get remembered order IDs for the current browser profile.
  655. */
  656. function getRememberedOrderIds(): array {
  657. return readSignedOrderHistoryCookie();
  658. }
  659. /**
  660. * Remember a newly created order ID in browser history cookie.
  661. */
  662. function rememberOrderId(string $orderId): void {
  663. if (!isValidOrderHistoryOrderId($orderId)) {
  664. return;
  665. }
  666. $existingIds = getRememberedOrderIds();
  667. $updatedIds = [$orderId];
  668. foreach ($existingIds as $existingId) {
  669. if ($existingId !== $orderId) {
  670. $updatedIds[] = $existingId;
  671. }
  672. }
  673. $maxIds = getOrderHistoryMaxIds();
  674. if (count($updatedIds) > $maxIds) {
  675. $updatedIds = array_slice($updatedIds, 0, $maxIds);
  676. }
  677. writeSignedOrderHistoryCookie($updatedIds);
  678. }
  679. /**
  680. * Read and validate signed browser order history cookie.
  681. */
  682. function readSignedOrderHistoryCookie(): array {
  683. $cookieName = getOrderHistoryCookieName();
  684. if (!isset($_COOKIE[$cookieName]) || !is_string($_COOKIE[$cookieName])) {
  685. return [];
  686. }
  687. $secret = getOrderHistorySecret();
  688. if ($secret === '') {
  689. return [];
  690. }
  691. $cookieValue = $_COOKIE[$cookieName];
  692. $parts = explode('.', $cookieValue, 2);
  693. if (count($parts) !== 2) {
  694. return [];
  695. }
  696. $encodedPayload = $parts[0];
  697. $signature = $parts[1];
  698. if ($encodedPayload === '' || $signature === '') {
  699. return [];
  700. }
  701. $expectedSignature = hash_hmac('sha256', $encodedPayload, $secret);
  702. if (!hash_equals($expectedSignature, $signature)) {
  703. return [];
  704. }
  705. $payloadJson = base64UrlDecode($encodedPayload);
  706. if ($payloadJson === null) {
  707. return [];
  708. }
  709. $payload = json_decode($payloadJson, true);
  710. if (!is_array($payload)) {
  711. return [];
  712. }
  713. $version = isset($payload['v']) ? (int)$payload['v'] : 0;
  714. if ($version !== 1) {
  715. return [];
  716. }
  717. $ids = isset($payload['ids']) && is_array($payload['ids']) ? $payload['ids'] : [];
  718. return sanitizeOrderHistoryIds($ids);
  719. }
  720. /**
  721. * Write signed browser order history cookie.
  722. */
  723. function writeSignedOrderHistoryCookie(array $ids): void {
  724. if (headers_sent()) {
  725. return;
  726. }
  727. $secret = getOrderHistorySecret();
  728. if ($secret === '') {
  729. return;
  730. }
  731. $sanitizedIds = sanitizeOrderHistoryIds($ids);
  732. $payload = [
  733. 'v' => 1,
  734. 'ids' => $sanitizedIds,
  735. 'iat' => time()
  736. ];
  737. $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE);
  738. if ($payloadJson === false) {
  739. return;
  740. }
  741. $encodedPayload = base64UrlEncode($payloadJson);
  742. $signature = hash_hmac('sha256', $encodedPayload, $secret);
  743. $cookieValue = $encodedPayload . '.' . $signature;
  744. $expires = time() + (getOrderHistoryTtlDays() * 86400);
  745. $success = setcookie(getOrderHistoryCookieName(), $cookieValue, [
  746. 'expires' => $expires,
  747. 'path' => getOrderHistoryCookiePath(),
  748. 'secure' => isHttpsRequest(),
  749. 'httponly' => true,
  750. 'samesite' => 'Lax'
  751. ]);
  752. if ($success) {
  753. $_COOKIE[getOrderHistoryCookieName()] = $cookieValue;
  754. }
  755. }
  756. /**
  757. * Build a safe, deduplicated order history ID list.
  758. */
  759. function sanitizeOrderHistoryIds(array $ids): array {
  760. $result = [];
  761. $seen = [];
  762. $maxIds = getOrderHistoryMaxIds();
  763. foreach ($ids as $id) {
  764. if (!is_string($id) || !isValidOrderHistoryOrderId($id)) {
  765. continue;
  766. }
  767. if (isset($seen[$id])) {
  768. continue;
  769. }
  770. $seen[$id] = true;
  771. $result[] = $id;
  772. if (count($result) >= $maxIds) {
  773. break;
  774. }
  775. }
  776. return $result;
  777. }
  778. /**
  779. * Check if order ID matches configured pattern.
  780. */
  781. function isValidOrderHistoryOrderId($orderId): bool {
  782. if (!is_string($orderId) || $orderId === '') {
  783. return false;
  784. }
  785. $prefix = defined('ORDER_PREFIX') ? ORDER_PREFIX : 'ORD';
  786. $pattern = '/^' . preg_quote($prefix, '/') . '-\d{4}-\d+$/';
  787. return preg_match($pattern, $orderId) === 1;
  788. }
  789. /**
  790. * Base64url encode helper.
  791. */
  792. function base64UrlEncode(string $data): string {
  793. return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
  794. }
  795. /**
  796. * Base64url decode helper.
  797. */
  798. function base64UrlDecode(string $data): ?string {
  799. $remainder = strlen($data) % 4;
  800. if ($remainder > 0) {
  801. $data .= str_repeat('=', 4 - $remainder);
  802. }
  803. $decoded = base64_decode(strtr($data, '-_', '+/'), true);
  804. if ($decoded === false) {
  805. return null;
  806. }
  807. return $decoded;
  808. }
  809. /**
  810. * Get cookie name for order history.
  811. */
  812. function getOrderHistoryCookieName(): string {
  813. return defined('ORDER_HISTORY_COOKIE_NAME') ? ORDER_HISTORY_COOKIE_NAME : 'order_history';
  814. }
  815. /**
  816. * Get secret for order history cookie signing.
  817. */
  818. function getOrderHistorySecret(): string {
  819. return defined('ORDER_HISTORY_COOKIE_SECRET') ? (string) ORDER_HISTORY_COOKIE_SECRET : '';
  820. }
  821. /**
  822. * Get maximum number of remembered order IDs.
  823. */
  824. function getOrderHistoryMaxIds(): int {
  825. $maxIds = defined('ORDER_HISTORY_MAX_IDS') ? (int) ORDER_HISTORY_MAX_IDS : 10;
  826. return $maxIds > 0 ? $maxIds : 10;
  827. }
  828. /**
  829. * Get order history cookie retention days.
  830. */
  831. function getOrderHistoryTtlDays(): int {
  832. $ttlDays = defined('ORDER_HISTORY_COOKIE_TTL_DAYS') ? (int) ORDER_HISTORY_COOKIE_TTL_DAYS : 365;
  833. return $ttlDays > 0 ? $ttlDays : 365;
  834. }
  835. /**
  836. * Get cookie path from SITE_URL.
  837. */
  838. function getOrderHistoryCookiePath(): string {
  839. $siteUrl = defined('SITE_URL') ? trim((string) SITE_URL) : '';
  840. if (strpos($siteUrl, '://') !== false) {
  841. $parsedPath = parse_url($siteUrl, PHP_URL_PATH);
  842. $siteUrl = is_string($parsedPath) ? $parsedPath : '';
  843. }
  844. if ($siteUrl === '' || $siteUrl === '/') {
  845. return '/';
  846. }
  847. if ($siteUrl[0] !== '/') {
  848. $siteUrl = '/' . $siteUrl;
  849. }
  850. return rtrim($siteUrl, '/');
  851. }
  852. /**
  853. * Detect whether current request uses HTTPS.
  854. */
  855. function isHttpsRequest(): bool {
  856. if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
  857. return true;
  858. }
  859. if (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443) {
  860. return true;
  861. }
  862. if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
  863. return true;
  864. }
  865. return false;
  866. }
  867. /**
  868. * Check if reservation is hidden (spam/deleted)
  869. */
  870. function isReservationHidden($reservation) {
  871. return isset($reservation['is_hidden']) && $reservation['is_hidden'] === true;
  872. }
  873. /**
  874. * Save reservations
  875. */
  876. function saveReservations($reservations) {
  877. $data = ['reservations' => $reservations];
  878. writeJsonFile(RESERVATIONS_FILE, $data);
  879. }
  880. /**
  881. * Generate order number
  882. * Pattern: PREFIX-YEAR-SEQ
  883. */
  884. function generateReservationId() {
  885. $reservations = getReservations();
  886. $year = date('Y');
  887. $prefix = defined('ORDER_PREFIX') ? ORDER_PREFIX : 'ORD';
  888. $max = 0;
  889. $pattern = '/^' . preg_quote($prefix, '/') . '-\\d{4}-(\\d+)$/';
  890. foreach ($reservations as $reservation) {
  891. if (!isset($reservation['id'])) {
  892. continue;
  893. }
  894. if (preg_match($pattern, $reservation['id'], $matches)) {
  895. $num = (int)$matches[1];
  896. if ($num > $max) {
  897. $max = $num;
  898. }
  899. }
  900. }
  901. $next = $max + 1;
  902. return sprintf('%s-%s-%03d', $prefix, $year, $next);
  903. }
  904. /**
  905. * Check if product has enough stock.
  906. */
  907. function checkStock($productId, $quantity, $size = null) {
  908. $product = getProductById($productId);
  909. if (!$product) {
  910. return false;
  911. }
  912. if (productUsesSizeStock($product) && $size !== null) {
  913. if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
  914. return false;
  915. }
  916. $sizeStock = isset($product['stock_by_size'][$size]) ? (int) $product['stock_by_size'][$size] : 0;
  917. return $sizeStock >= $quantity;
  918. }
  919. $stock = isset($product['stock']) ? (int) $product['stock'] : 0;
  920. return $stock >= $quantity;
  921. }
  922. /**
  923. * Get stock for a product.
  924. */
  925. function getStock($product, $size = null) {
  926. if (productUsesSizeStock($product) && $size !== null) {
  927. if (isset($product['stock_by_size']) && is_array($product['stock_by_size'])) {
  928. return isset($product['stock_by_size'][$size]) ? (int) $product['stock_by_size'][$size] : 0;
  929. }
  930. }
  931. return isset($product['stock']) ? (int) $product['stock'] : 0;
  932. }
  933. /**
  934. * Get total stock for a product.
  935. */
  936. function getTotalStock($product) {
  937. if (productUsesSizeStock($product) && isset($product['stock_by_size']) && is_array($product['stock_by_size'])) {
  938. return array_sum($product['stock_by_size']);
  939. }
  940. return isset($product['stock']) ? (int) $product['stock'] : 0;
  941. }
  942. /**
  943. * Allocate stock for reservation
  944. */
  945. function allocateStock($productId, $quantity, $size = null) {
  946. $products = getProducts();
  947. foreach ($products as &$product) {
  948. if ($product['id'] == $productId) {
  949. if (productUsesSizeStock($product) && $size !== null) {
  950. if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
  951. $product['stock_by_size'] = [];
  952. }
  953. if (!isset($product['stock_by_size'][$size])) {
  954. $product['stock_by_size'][$size] = 0;
  955. }
  956. $product['stock_by_size'][$size] -= $quantity;
  957. if ($product['stock_by_size'][$size] < 0) {
  958. $product['stock_by_size'][$size] = 0;
  959. }
  960. } else {
  961. if (!isset($product['stock'])) {
  962. $product['stock'] = 0;
  963. }
  964. $product['stock'] -= $quantity;
  965. if ($product['stock'] < 0) {
  966. $product['stock'] = 0;
  967. }
  968. }
  969. break;
  970. }
  971. }
  972. saveProducts($products);
  973. }
  974. /**
  975. * Release stock from reservation
  976. */
  977. function releaseStock($productId, $quantity, $size = null) {
  978. $products = getProducts();
  979. foreach ($products as &$product) {
  980. if ($product['id'] == $productId) {
  981. if (productUsesSizeStock($product) && $size !== null) {
  982. if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
  983. $product['stock_by_size'] = [];
  984. }
  985. if (!isset($product['stock_by_size'][$size])) {
  986. $product['stock_by_size'][$size] = 0;
  987. }
  988. $product['stock_by_size'][$size] += $quantity;
  989. } else {
  990. if (!isset($product['stock'])) {
  991. $product['stock'] = 0;
  992. }
  993. $product['stock'] += $quantity;
  994. }
  995. break;
  996. }
  997. }
  998. saveProducts($products);
  999. }
  1000. /**
  1001. * Create new reservation
  1002. */
  1003. function createReservation($customerName, $customerEmail, $items) {
  1004. $reservations = getReservations();
  1005. // Validate stock for all items
  1006. foreach ($items as $item) {
  1007. $size = isset($item['size']) ? $item['size'] : null;
  1008. if (!checkStock($item['product_id'], $item['quantity'], $size)) {
  1009. $product = getProductById($item['product_id']);
  1010. $productName = $product ? $product['name'] : 'Produkt';
  1011. $sizeInfo = $size ? " (Größe: $size)" : '';
  1012. return ['success' => false, 'message' => "Nicht genügend Lagerbestand für: $productName$sizeInfo"];
  1013. }
  1014. }
  1015. // Allocate stock
  1016. foreach ($items as $item) {
  1017. $size = isset($item['size']) ? $item['size'] : null;
  1018. allocateStock($item['product_id'], $item['quantity'], $size);
  1019. }
  1020. // Create reservation
  1021. $now = new DateTime();
  1022. $expires = clone $now;
  1023. $expires->modify('+' . RESERVATION_EXPIRY_DAYS . ' days');
  1024. $reservation = [
  1025. 'id' => generateReservationId(),
  1026. 'customer_name' => $customerName,
  1027. 'customer_email' => $customerEmail,
  1028. 'items' => $items,
  1029. 'created' => $now->format('Y-m-d H:i:s'),
  1030. 'expires' => $expires->format('Y-m-d H:i:s'),
  1031. 'status' => 'open',
  1032. 'picked_up' => false,
  1033. 'type' => 'regular',
  1034. 'is_hidden' => false
  1035. ];
  1036. $reservations[] = $reservation;
  1037. saveReservations($reservations);
  1038. // Send confirmation emails
  1039. sendReservationEmails($reservation);
  1040. return ['success' => true, 'reservation' => $reservation];
  1041. }
  1042. /**
  1043. * Create new backorder reservation
  1044. */
  1045. function createBackorderReservation($customerName, $customerEmail, $items) {
  1046. $reservations = getReservations();
  1047. $now = new DateTime();
  1048. $reservation = [
  1049. 'id' => generateReservationId(),
  1050. 'customer_name' => $customerName,
  1051. 'customer_email' => $customerEmail,
  1052. 'items' => $items,
  1053. 'created' => $now->format('Y-m-d H:i:s'),
  1054. 'expires' => '',
  1055. 'status' => 'open',
  1056. 'picked_up' => false,
  1057. 'type' => 'backorder',
  1058. 'backorder_status' => 'pending',
  1059. 'is_hidden' => false
  1060. ];
  1061. $reservations[] = $reservation;
  1062. saveReservations($reservations);
  1063. // Send confirmation emails
  1064. sendBackorderEmails($reservation);
  1065. return ['success' => true, 'reservation' => $reservation];
  1066. }
  1067. /**
  1068. * Mark reservation as picked up
  1069. */
  1070. function markReservationPickedUp($reservationId) {
  1071. $reservations = getReservations();
  1072. foreach ($reservations as &$reservation) {
  1073. if ($reservation['id'] === $reservationId) {
  1074. if (isReservationHidden($reservation)) {
  1075. break;
  1076. }
  1077. $reservation['picked_up'] = true;
  1078. $reservation['status'] = 'picked_up';
  1079. break;
  1080. }
  1081. }
  1082. saveReservations($reservations);
  1083. }
  1084. /**
  1085. * Mark reservation/backorder as spam/deleted and hide it from non-admin views.
  1086. * For open regular reservations we release stock, because the order is discarded.
  1087. */
  1088. function markReservationHidden($reservationId) {
  1089. $reservations = getReservations();
  1090. foreach ($reservations as &$reservation) {
  1091. if ($reservation['id'] !== $reservationId) {
  1092. continue;
  1093. }
  1094. if (isReservationHidden($reservation)) {
  1095. return ['success' => false, 'message' => 'Bestellung ist bereits als Spam/Gelöscht markiert.'];
  1096. }
  1097. $isBackorder = isset($reservation['type']) && $reservation['type'] === 'backorder';
  1098. if (!$isBackorder && isset($reservation['status']) && $reservation['status'] === 'open' && empty($reservation['picked_up'])) {
  1099. foreach ($reservation['items'] as $item) {
  1100. $size = isset($item['size']) ? $item['size'] : null;
  1101. releaseStock($item['product_id'], $item['quantity'], $size);
  1102. }
  1103. $reservation['status'] = 'deleted';
  1104. }
  1105. $reservation['is_hidden'] = true;
  1106. $reservation['hidden_at'] = date('Y-m-d H:i:s');
  1107. $reservation['hidden_reason'] = 'spam_deleted';
  1108. saveReservations($reservations);
  1109. return ['success' => true];
  1110. }
  1111. return ['success' => false, 'message' => 'Bestellung nicht gefunden.'];
  1112. }
  1113. /**
  1114. * Check and expire old reservations
  1115. */
  1116. function expireOldReservations() {
  1117. $reservations = getReservations();
  1118. $now = new DateTime();
  1119. $changed = false;
  1120. foreach ($reservations as &$reservation) {
  1121. if (isReservationHidden($reservation)) {
  1122. continue;
  1123. }
  1124. if ($reservation['status'] === 'open' && !$reservation['picked_up']) {
  1125. if (isset($reservation['type']) && $reservation['type'] === 'backorder') {
  1126. continue;
  1127. }
  1128. if (empty($reservation['expires'])) {
  1129. continue;
  1130. }
  1131. $expires = new DateTime($reservation['expires']);
  1132. if ($now > $expires) {
  1133. $reservation['status'] = 'expired';
  1134. // Release stock
  1135. foreach ($reservation['items'] as $item) {
  1136. $size = isset($item['size']) ? $item['size'] : null;
  1137. releaseStock($item['product_id'], $item['quantity'], $size);
  1138. }
  1139. $changed = true;
  1140. }
  1141. }
  1142. }
  1143. if ($changed) {
  1144. saveReservations($reservations);
  1145. }
  1146. }
  1147. /**
  1148. * Check if all items are in stock
  1149. */
  1150. function canFulfillReservationItems($items) {
  1151. foreach ($items as $item) {
  1152. $size = isset($item['size']) ? $item['size'] : null;
  1153. if (!checkStock($item['product_id'], $item['quantity'], $size)) {
  1154. return false;
  1155. }
  1156. }
  1157. return true;
  1158. }
  1159. /**
  1160. * Mark backorder as available
  1161. */
  1162. function markBackorderAvailable($reservationId) {
  1163. $reservations = getReservations();
  1164. foreach ($reservations as &$reservation) {
  1165. if ($reservation['id'] === $reservationId) {
  1166. if (isReservationHidden($reservation)) {
  1167. return ['success' => false, 'message' => 'Diese Vorbestellung ist als Spam/Gelöscht markiert.'];
  1168. }
  1169. if (!isset($reservation['type']) || $reservation['type'] !== 'backorder') {
  1170. return ['success' => false, 'message' => 'Diese Vorbestellung wurde bereits in eine Bestellung umgewandelt.'];
  1171. }
  1172. if (isset($reservation['backorder_status']) && $reservation['backorder_status'] === 'notified') {
  1173. return ['success' => false, 'message' => 'Diese Vorbestellung wurde bereits informiert.'];
  1174. }
  1175. if (!canFulfillReservationItems($reservation['items'])) {
  1176. return ['success' => false, 'message' => 'Nicht alle Artikel sind verfügbar.'];
  1177. }
  1178. foreach ($reservation['items'] as $item) {
  1179. $size = isset($item['size']) ? $item['size'] : null;
  1180. allocateStock($item['product_id'], $item['quantity'], $size);
  1181. }
  1182. $now = new DateTime();
  1183. $expires = clone $now;
  1184. $expires->modify('+' . RESERVATION_EXPIRY_DAYS . ' days');
  1185. $reservation['type'] = 'regular';
  1186. $reservation['status'] = 'open';
  1187. $reservation['picked_up'] = false;
  1188. $reservation['expires'] = $expires->format('Y-m-d H:i:s');
  1189. if (isset($reservation['backorder_status'])) {
  1190. unset($reservation['backorder_status']);
  1191. }
  1192. saveReservations($reservations);
  1193. sendBackorderAvailableEmail($reservation);
  1194. return ['success' => true, 'reservation' => $reservation];
  1195. }
  1196. }
  1197. return ['success' => false, 'message' => 'Vorbestellung nicht gefunden.'];
  1198. }
  1199. /**
  1200. * Sanitize input
  1201. */
  1202. function sanitize($input) {
  1203. return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8');
  1204. }
  1205. /**
  1206. * Format price
  1207. */
  1208. function formatPrice($price) {
  1209. return number_format($price, 2, ',', '.') . ' €';
  1210. }
  1211. /**
  1212. * Format date
  1213. */
  1214. function formatDate($dateString) {
  1215. $date = new DateTime($dateString);
  1216. return $date->format('d.m.Y H:i');
  1217. }
  1218. /**
  1219. * Send email
  1220. */
  1221. function sendEmail($to, $subject, $message, $isHtml = true) {
  1222. $headers = [];
  1223. $headers[] = 'From: ' . FROM_NAME . ' <' . FROM_EMAIL . '>';
  1224. $headers[] = 'Reply-To: ' . FROM_EMAIL;
  1225. $headers[] = 'X-Mailer: PHP/' . phpversion();
  1226. if ($isHtml) {
  1227. $headers[] = 'MIME-Version: 1.0';
  1228. $headers[] = 'Content-type: text/html; charset=UTF-8';
  1229. }
  1230. return mail($to, $subject, $message, implode("\r\n", $headers));
  1231. }
  1232. /**
  1233. * Get all admin notification recipients from admin accounts.
  1234. * Falls back to ADMIN_EMAIL if no account email is configured.
  1235. */
  1236. function getAdminNotificationEmails() {
  1237. $accounts = getAdminAccounts();
  1238. $emails = [];
  1239. foreach ($accounts as $account) {
  1240. if (!isset($account['email'])) {
  1241. continue;
  1242. }
  1243. $email = normalizeAdminEmail($account['email']);
  1244. if (!isValidAdminEmail($email)) {
  1245. continue;
  1246. }
  1247. $emails[] = $email;
  1248. }
  1249. if (empty($emails)) {
  1250. $fallbackEmail = getDefaultAdminEmail();
  1251. if ($fallbackEmail !== '') {
  1252. $emails[] = $fallbackEmail;
  1253. }
  1254. }
  1255. return array_values(array_unique($emails));
  1256. }
  1257. /**
  1258. * Send admin notifications to all configured recipients.
  1259. */
  1260. function sendAdminNotificationEmails($subject, $message, $isHtml = true) {
  1261. $emails = getAdminNotificationEmails();
  1262. if (empty($emails)) {
  1263. return false;
  1264. }
  1265. $sent = false;
  1266. foreach ($emails as $email) {
  1267. $result = sendEmail($email, $subject, $message, $isHtml);
  1268. if ($result) {
  1269. $sent = true;
  1270. }
  1271. }
  1272. return $sent;
  1273. }
  1274. /**
  1275. * Send reservation confirmation emails
  1276. */
  1277. function sendReservationEmails($reservation) {
  1278. $products = getProducts();
  1279. // Build items list
  1280. $itemsHtml = '<ul style="list-style: none; margin: 0; padding: 0;">';
  1281. foreach ($reservation['items'] as $item) {
  1282. $product = getProductById($item['product_id']);
  1283. if ($product) {
  1284. $sizeInfo = '';
  1285. if (isset($item['size']) && !empty($item['size'])) {
  1286. $sizeInfo = ' - Größe: ' . htmlspecialchars($item['size']);
  1287. }
  1288. $itemsHtml .= '<li style="margin: 0 0 0.6rem 0; padding: 0.75rem 0.9rem; background: #28292a; border: 1px solid #4a5263; border-radius: 6px;"><strong style="color: #cac300;">' . htmlspecialchars($product['name']) . '</strong>' . $sizeInfo . ' - Menge: <strong>' . (int) $item['quantity'] . '</strong></li>';
  1289. }
  1290. }
  1291. $itemsHtml .= '</ul>';
  1292. // Customer email
  1293. $customerSubject = 'Ihre Reservierung bei ' . SITE_NAME;
  1294. $customerMessage = '
  1295. <html>
  1296. <head>
  1297. <meta charset="UTF-8">
  1298. </head>
  1299. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1300. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1301. <h2 style="color: #cac300; margin-top: 0;">Reservierung bestätigt</h2>
  1302. <p>Sehr geehrte/r ' . htmlspecialchars($reservation['customer_name']) . ',</p>
  1303. <p>vielen Dank für Ihre Reservierung bei ' . SITE_NAME . '.</p>
  1304. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px; text-align: center;">
  1305. <h3 style="margin-top: 0; color: #f5f7fb;">Ihre Bestellnummer:</h3>
  1306. <p style="margin: 0; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</p>
  1307. </div>
  1308. <h3>Reservierungsdetails:</h3>
  1309. <p><strong>Bestellnummer:</strong> ' . htmlspecialchars($reservation['id']) . '</p>
  1310. <p><strong>Erstellt am:</strong> ' . formatDate($reservation['created']) . '</p>
  1311. <p><strong>Gültig bis:</strong> ' . formatDate($reservation['expires']) . '</p>
  1312. <h3>Reservierte Artikel:</h3>
  1313. <div style="background: #303745; border: 1px solid #4a5263; border-left: 4px solid #cac300; border-radius: 8px; padding: 1rem;">' . $itemsHtml . '</div>
  1314. <p><strong>Wichtig:</strong> Bitte nennen Sie diese Bestellnummer bei der Abholung. Die Reservierung ist bis zum ' . formatDate($reservation['expires']) . ' gültig.</p>
  1315. <p>Mit freundlichen Grüßen<br>' . SITE_NAME . '</p>
  1316. </div>
  1317. </body>
  1318. </html>';
  1319. sendEmail($reservation['customer_email'], $customerSubject, $customerMessage);
  1320. // Admin email
  1321. $adminSubject = 'Neue Reservierung: ' . $reservation['id'];
  1322. $adminMessage = '
  1323. <html>
  1324. <head>
  1325. <meta charset="UTF-8">
  1326. </head>
  1327. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1328. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1329. <h2 style="color: #cac300; margin-top: 0;">Neue Reservierung</h2>
  1330. <p>Eine neue Reservierung wurde erstellt:</p>
  1331. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px;">
  1332. <h3 style="margin-top: 0;">Bestellnummer:</h3>
  1333. <p style="margin: 0; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</p>
  1334. </div>
  1335. <h3>Kundendaten:</h3>
  1336. <p><strong>Name:</strong> ' . htmlspecialchars($reservation['customer_name']) . '</p>
  1337. <p><strong>E-Mail:</strong> ' . htmlspecialchars($reservation['customer_email']) . '</p>
  1338. <h3>Reservierungsdetails:</h3>
  1339. <p><strong>Bestellnummer:</strong> ' . htmlspecialchars($reservation['id']) . '</p>
  1340. <p><strong>Erstellt am:</strong> ' . formatDate($reservation['created']) . '</p>
  1341. <p><strong>Gültig bis:</strong> ' . formatDate($reservation['expires']) . '</p>
  1342. <h3>Reservierte Artikel:</h3>
  1343. <div style="background: #303745; border: 1px solid #4a5263; border-left: 4px solid #cac300; border-radius: 8px; padding: 1rem;">' . $itemsHtml . '</div>
  1344. </div>
  1345. </body>
  1346. </html>';
  1347. sendAdminNotificationEmails($adminSubject, $adminMessage);
  1348. }
  1349. /**
  1350. * Send backorder confirmation emails
  1351. */
  1352. function sendBackorderEmails($reservation) {
  1353. // Build items list
  1354. $itemsHtml = '<ul style="list-style: none; margin: 0; padding: 0;">';
  1355. foreach ($reservation['items'] as $item) {
  1356. $product = getProductById($item['product_id']);
  1357. if ($product) {
  1358. $sizeInfo = '';
  1359. if (isset($item['size']) && !empty($item['size'])) {
  1360. $sizeInfo = ' - Größe: ' . htmlspecialchars($item['size']);
  1361. }
  1362. $itemsHtml .= '<li style="margin: 0 0 0.6rem 0; padding: 0.75rem 0.9rem; background: #28292a; border: 1px solid #4a5263; border-radius: 6px;"><strong style="color: #cac300;">' . htmlspecialchars($product['name']) . '</strong>' . $sizeInfo . ' - Menge: <strong>' . (int) $item['quantity'] . '</strong></li>';
  1363. }
  1364. }
  1365. $itemsHtml .= '</ul>';
  1366. // Customer email
  1367. $customerSubject = 'Vorbestellung bei ' . SITE_NAME;
  1368. $customerMessage = '
  1369. <html>
  1370. <head>
  1371. <meta charset="UTF-8">
  1372. </head>
  1373. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1374. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1375. <h2 style="color: #cac300; margin-top: 0;">Vorbestellung bestätigt</h2>
  1376. <p>Sehr geehrte/r ' . htmlspecialchars($reservation['customer_name']) . ',</p>
  1377. <p>vielen Dank für Ihre Vorbestellung bei ' . SITE_NAME . '.</p>
  1378. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px; text-align: center;">
  1379. <h3 style="margin-top: 0; color: #f5f7fb;">Ihre Bestellnummer:</h3>
  1380. <p style="margin: 0; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</p>
  1381. </div>
  1382. <h3>Vorbestellungsdetails:</h3>
  1383. <p><strong>Bestellnummer:</strong> ' . htmlspecialchars($reservation['id']) . '</p>
  1384. <p><strong>Erstellt am:</strong> ' . formatDate($reservation['created']) . '</p>
  1385. <h3>Vorbestellte Artikel:</h3>
  1386. <div style="background: #303745; border: 1px solid #4a5263; border-left: 4px solid #cac300; border-radius: 8px; padding: 1rem;">' . $itemsHtml . '</div>
  1387. <div style="background: #28292a; border: 2px solid #cf2e2e; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px;">
  1388. <strong>Hinweis:</strong> Die Lieferzeiten sind nicht bekannt, da die Bestellung in Chargen erfolgt.
  1389. </div>
  1390. <p>Wir informieren Sie, sobald die komplette Vorbestellung zur Abholung bereit ist.</p>
  1391. <p>Mit freundlichen Grüßen<br>' . SITE_NAME . '</p>
  1392. </div>
  1393. </body>
  1394. </html>';
  1395. sendEmail($reservation['customer_email'], $customerSubject, $customerMessage);
  1396. // Admin email
  1397. $adminSubject = 'Neue Vorbestellung: ' . $reservation['id'];
  1398. $adminMessage = '
  1399. <html>
  1400. <head>
  1401. <meta charset="UTF-8">
  1402. </head>
  1403. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1404. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1405. <h2 style="color: #cac300; margin-top: 0;">Neue Vorbestellung</h2>
  1406. <p>Eine neue Vorbestellung wurde erstellt:</p>
  1407. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px;">
  1408. <h3 style="margin-top: 0;">Bestellnummer:</h3>
  1409. <p style="margin: 0; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</p>
  1410. </div>
  1411. <h3>Kundendaten:</h3>
  1412. <p><strong>Name:</strong> ' . htmlspecialchars($reservation['customer_name']) . '</p>
  1413. <p><strong>E-Mail:</strong> ' . htmlspecialchars($reservation['customer_email']) . '</p>
  1414. <h3>Vorbestellungsdetails:</h3>
  1415. <p><strong>Bestellnummer:</strong> ' . htmlspecialchars($reservation['id']) . '</p>
  1416. <p><strong>Erstellt am:</strong> ' . formatDate($reservation['created']) . '</p>
  1417. <h3>Vorbestellte Artikel:</h3>
  1418. <div style="background: #303745; border: 1px solid #4a5263; border-left: 4px solid #cac300; border-radius: 8px; padding: 1rem;">' . $itemsHtml . '</div>
  1419. </div>
  1420. </body>
  1421. </html>';
  1422. sendAdminNotificationEmails($adminSubject, $adminMessage);
  1423. }
  1424. /**
  1425. * Send backorder availability email
  1426. */
  1427. function sendBackorderAvailableEmail($reservation) {
  1428. $itemsHtml = '<ul>';
  1429. foreach ($reservation['items'] as $item) {
  1430. $product = getProductById($item['product_id']);
  1431. if ($product) {
  1432. $sizeInfo = '';
  1433. if (isset($item['size']) && !empty($item['size'])) {
  1434. $sizeInfo = ' - Größe: ' . htmlspecialchars($item['size']);
  1435. }
  1436. $itemsHtml .= '<li>' . htmlspecialchars($product['name']) . $sizeInfo . ' - Menge: ' . $item['quantity'] . '</li>';
  1437. }
  1438. }
  1439. $itemsHtml .= '</ul>';
  1440. $subject = 'Ihre Vorbestellung ist zur Abholung bereit';
  1441. $message = '
  1442. <html>
  1443. <head>
  1444. <meta charset="UTF-8">
  1445. </head>
  1446. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1447. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1448. <h2 style="color: #cac300; margin-top: 0;">Vorbestellung zur Abholung bereit</h2>
  1449. <p>Sehr geehrte/r ' . htmlspecialchars($reservation['customer_name']) . ',</p>
  1450. <p>Ihre komplette Vorbestellung ist jetzt zur Abholung bereit.</p>
  1451. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px; text-align: center;">
  1452. <h3 style="margin-top: 0; color: #f5f7fb;">Ihre Bestellnummer:</h3>
  1453. <h2 style="font-size: 2rem; letter-spacing: 0.2rem; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</h2>
  1454. </div>
  1455. <h3>Bereitliegende Artikel:</h3>
  1456. ' . $itemsHtml . '
  1457. <p>Bitte nennen Sie die Bestellnummer bei der Abholung.</p>
  1458. <p>Mit freundlichen Grüßen<br>' . SITE_NAME . '</p>
  1459. </div>
  1460. </body>
  1461. </html>';
  1462. sendEmail($reservation['customer_email'], $subject, $message);
  1463. }