functions.php 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389
  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 all products
  208. */
  209. function getProducts() {
  210. $data = readJsonFile(PRODUCTS_FILE);
  211. return isset($data['products']) ? $data['products'] : [];
  212. }
  213. /**
  214. * Get product by ID
  215. */
  216. function getProductById($id) {
  217. $products = getProducts();
  218. foreach ($products as $product) {
  219. if ($product['id'] == $id) {
  220. return $product;
  221. }
  222. }
  223. return null;
  224. }
  225. /**
  226. * Save products
  227. */
  228. function saveProducts($products) {
  229. $data = ['products' => $products];
  230. writeJsonFile(PRODUCTS_FILE, $data);
  231. }
  232. /**
  233. * Resolve FAQ file path and force storage inside DATA_DIR.
  234. */
  235. function getFaqFilePath(): string {
  236. $dataDir = defined('DATA_DIR') && is_string(DATA_DIR) ? DATA_DIR : (dirname(__DIR__) . '/data/');
  237. $defaultPath = rtrim($dataDir, '/\\') . '/faq.json';
  238. if (!defined('FAQ_FILE') || !is_string(FAQ_FILE) || FAQ_FILE === '') {
  239. return $defaultPath;
  240. }
  241. $configuredPath = FAQ_FILE;
  242. $normalizedDataDir = str_replace('\\', '/', rtrim($dataDir, '/\\')) . '/';
  243. $normalizedConfigured = str_replace('\\', '/', $configuredPath);
  244. if (strpos($normalizedConfigured, $normalizedDataDir) !== 0) {
  245. return $defaultPath;
  246. }
  247. return $configuredPath;
  248. }
  249. /**
  250. * Get FAQ markdown content from JSON store.
  251. */
  252. function getFaqContent(): string {
  253. $defaultContent = "# FAQ\n\nHier kann der FAQ-Inhalt im Admin-Bereich bearbeitet werden.";
  254. $data = readJsonFile(getFaqFilePath());
  255. if (!is_array($data)) {
  256. return $defaultContent;
  257. }
  258. if (!isset($data['content']) || !is_string($data['content'])) {
  259. return $defaultContent;
  260. }
  261. return $data['content'];
  262. }
  263. /**
  264. * Save FAQ markdown content to JSON store.
  265. */
  266. function saveFaqContent(string $markdown): void {
  267. writeJsonFile(getFaqFilePath(), ['content' => (string) $markdown]);
  268. }
  269. /**
  270. * Render inline markdown safely (bold + italic).
  271. */
  272. function renderFaqInlineMarkdown(string $text): string {
  273. $escaped = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
  274. // Apply markdown after escaping so raw HTML is never executed.
  275. $escaped = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $escaped);
  276. $escaped = preg_replace('/(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)/s', '<em>$1</em>', $escaped);
  277. return $escaped;
  278. }
  279. /**
  280. * Render a minimal safe markdown subset for FAQ content.
  281. */
  282. function renderFaqMarkdown(string $markdown): string {
  283. $normalized = str_replace(["\r\n", "\r"], "\n", $markdown);
  284. $lines = explode("\n", $normalized);
  285. $htmlParts = [];
  286. $paragraphLines = [];
  287. $listType = '';
  288. $flushParagraph = function () use (&$paragraphLines, &$htmlParts): void {
  289. if (empty($paragraphLines)) {
  290. return;
  291. }
  292. $renderedLines = [];
  293. foreach ($paragraphLines as $line) {
  294. $renderedLines[] = renderFaqInlineMarkdown($line);
  295. }
  296. $htmlParts[] = '<p>' . implode("<br>\n", $renderedLines) . '</p>';
  297. $paragraphLines = [];
  298. };
  299. $closeList = function () use (&$listType, &$htmlParts): void {
  300. if ($listType === '') {
  301. return;
  302. }
  303. $htmlParts[] = '</' . $listType . '>';
  304. $listType = '';
  305. };
  306. foreach ($lines as $line) {
  307. $line = rtrim($line);
  308. $trimmed = trim($line);
  309. if ($trimmed === '') {
  310. $flushParagraph();
  311. $closeList();
  312. continue;
  313. }
  314. if (preg_match('/^(#{1,3})\s+(.+)$/', $trimmed, $matches) === 1) {
  315. $flushParagraph();
  316. $closeList();
  317. $level = strlen($matches[1]);
  318. $htmlParts[] = '<h' . $level . '>' . renderFaqInlineMarkdown($matches[2]) . '</h' . $level . '>';
  319. continue;
  320. }
  321. if (preg_match('/^\s*[-*]\s+(.+)$/', $line, $matches) === 1) {
  322. $flushParagraph();
  323. if ($listType !== 'ul') {
  324. $closeList();
  325. $listType = 'ul';
  326. $htmlParts[] = '<ul>';
  327. }
  328. $htmlParts[] = '<li>' . renderFaqInlineMarkdown($matches[1]) . '</li>';
  329. continue;
  330. }
  331. if (preg_match('/^\s*\d+\.\s+(.+)$/', $line, $matches) === 1) {
  332. $flushParagraph();
  333. if ($listType !== 'ol') {
  334. $closeList();
  335. $listType = 'ol';
  336. $htmlParts[] = '<ol>';
  337. }
  338. $htmlParts[] = '<li>' . renderFaqInlineMarkdown($matches[1]) . '</li>';
  339. continue;
  340. }
  341. $closeList();
  342. $paragraphLines[] = $trimmed;
  343. }
  344. $flushParagraph();
  345. $closeList();
  346. if (empty($htmlParts)) {
  347. return '<p>Keine FAQ-Inhalte vorhanden.</p>';
  348. }
  349. return implode("\n", $htmlParts);
  350. }
  351. /**
  352. * Get all reservations
  353. */
  354. function getReservations() {
  355. $data = readJsonFile(RESERVATIONS_FILE);
  356. return isset($data['reservations']) ? $data['reservations'] : [];
  357. }
  358. /**
  359. * Get reservation by order number
  360. */
  361. function getReservationByOrderNumber($orderNumber) {
  362. $reservations = getReservations();
  363. foreach ($reservations as $reservation) {
  364. if (isset($reservation['id']) && $reservation['id'] === $orderNumber && !isReservationHidden($reservation)) {
  365. return $reservation;
  366. }
  367. }
  368. return null;
  369. }
  370. /**
  371. * Get remembered order IDs for the current browser profile.
  372. */
  373. function getRememberedOrderIds(): array {
  374. return readSignedOrderHistoryCookie();
  375. }
  376. /**
  377. * Remember a newly created order ID in browser history cookie.
  378. */
  379. function rememberOrderId(string $orderId): void {
  380. if (!isValidOrderHistoryOrderId($orderId)) {
  381. return;
  382. }
  383. $existingIds = getRememberedOrderIds();
  384. $updatedIds = [$orderId];
  385. foreach ($existingIds as $existingId) {
  386. if ($existingId !== $orderId) {
  387. $updatedIds[] = $existingId;
  388. }
  389. }
  390. $maxIds = getOrderHistoryMaxIds();
  391. if (count($updatedIds) > $maxIds) {
  392. $updatedIds = array_slice($updatedIds, 0, $maxIds);
  393. }
  394. writeSignedOrderHistoryCookie($updatedIds);
  395. }
  396. /**
  397. * Read and validate signed browser order history cookie.
  398. */
  399. function readSignedOrderHistoryCookie(): array {
  400. $cookieName = getOrderHistoryCookieName();
  401. if (!isset($_COOKIE[$cookieName]) || !is_string($_COOKIE[$cookieName])) {
  402. return [];
  403. }
  404. $secret = getOrderHistorySecret();
  405. if ($secret === '') {
  406. return [];
  407. }
  408. $cookieValue = $_COOKIE[$cookieName];
  409. $parts = explode('.', $cookieValue, 2);
  410. if (count($parts) !== 2) {
  411. return [];
  412. }
  413. $encodedPayload = $parts[0];
  414. $signature = $parts[1];
  415. if ($encodedPayload === '' || $signature === '') {
  416. return [];
  417. }
  418. $expectedSignature = hash_hmac('sha256', $encodedPayload, $secret);
  419. if (!hash_equals($expectedSignature, $signature)) {
  420. return [];
  421. }
  422. $payloadJson = base64UrlDecode($encodedPayload);
  423. if ($payloadJson === null) {
  424. return [];
  425. }
  426. $payload = json_decode($payloadJson, true);
  427. if (!is_array($payload)) {
  428. return [];
  429. }
  430. $version = isset($payload['v']) ? (int)$payload['v'] : 0;
  431. if ($version !== 1) {
  432. return [];
  433. }
  434. $ids = isset($payload['ids']) && is_array($payload['ids']) ? $payload['ids'] : [];
  435. return sanitizeOrderHistoryIds($ids);
  436. }
  437. /**
  438. * Write signed browser order history cookie.
  439. */
  440. function writeSignedOrderHistoryCookie(array $ids): void {
  441. if (headers_sent()) {
  442. return;
  443. }
  444. $secret = getOrderHistorySecret();
  445. if ($secret === '') {
  446. return;
  447. }
  448. $sanitizedIds = sanitizeOrderHistoryIds($ids);
  449. $payload = [
  450. 'v' => 1,
  451. 'ids' => $sanitizedIds,
  452. 'iat' => time()
  453. ];
  454. $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE);
  455. if ($payloadJson === false) {
  456. return;
  457. }
  458. $encodedPayload = base64UrlEncode($payloadJson);
  459. $signature = hash_hmac('sha256', $encodedPayload, $secret);
  460. $cookieValue = $encodedPayload . '.' . $signature;
  461. $expires = time() + (getOrderHistoryTtlDays() * 86400);
  462. $success = setcookie(getOrderHistoryCookieName(), $cookieValue, [
  463. 'expires' => $expires,
  464. 'path' => getOrderHistoryCookiePath(),
  465. 'secure' => isHttpsRequest(),
  466. 'httponly' => true,
  467. 'samesite' => 'Lax'
  468. ]);
  469. if ($success) {
  470. $_COOKIE[getOrderHistoryCookieName()] = $cookieValue;
  471. }
  472. }
  473. /**
  474. * Build a safe, deduplicated order history ID list.
  475. */
  476. function sanitizeOrderHistoryIds(array $ids): array {
  477. $result = [];
  478. $seen = [];
  479. $maxIds = getOrderHistoryMaxIds();
  480. foreach ($ids as $id) {
  481. if (!is_string($id) || !isValidOrderHistoryOrderId($id)) {
  482. continue;
  483. }
  484. if (isset($seen[$id])) {
  485. continue;
  486. }
  487. $seen[$id] = true;
  488. $result[] = $id;
  489. if (count($result) >= $maxIds) {
  490. break;
  491. }
  492. }
  493. return $result;
  494. }
  495. /**
  496. * Check if order ID matches configured pattern.
  497. */
  498. function isValidOrderHistoryOrderId($orderId): bool {
  499. if (!is_string($orderId) || $orderId === '') {
  500. return false;
  501. }
  502. $prefix = defined('ORDER_PREFIX') ? ORDER_PREFIX : 'ORD';
  503. $pattern = '/^' . preg_quote($prefix, '/') . '-\d{4}-\d+$/';
  504. return preg_match($pattern, $orderId) === 1;
  505. }
  506. /**
  507. * Base64url encode helper.
  508. */
  509. function base64UrlEncode(string $data): string {
  510. return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
  511. }
  512. /**
  513. * Base64url decode helper.
  514. */
  515. function base64UrlDecode(string $data): ?string {
  516. $remainder = strlen($data) % 4;
  517. if ($remainder > 0) {
  518. $data .= str_repeat('=', 4 - $remainder);
  519. }
  520. $decoded = base64_decode(strtr($data, '-_', '+/'), true);
  521. if ($decoded === false) {
  522. return null;
  523. }
  524. return $decoded;
  525. }
  526. /**
  527. * Get cookie name for order history.
  528. */
  529. function getOrderHistoryCookieName(): string {
  530. return defined('ORDER_HISTORY_COOKIE_NAME') ? ORDER_HISTORY_COOKIE_NAME : 'order_history';
  531. }
  532. /**
  533. * Get secret for order history cookie signing.
  534. */
  535. function getOrderHistorySecret(): string {
  536. return defined('ORDER_HISTORY_COOKIE_SECRET') ? (string) ORDER_HISTORY_COOKIE_SECRET : '';
  537. }
  538. /**
  539. * Get maximum number of remembered order IDs.
  540. */
  541. function getOrderHistoryMaxIds(): int {
  542. $maxIds = defined('ORDER_HISTORY_MAX_IDS') ? (int) ORDER_HISTORY_MAX_IDS : 10;
  543. return $maxIds > 0 ? $maxIds : 10;
  544. }
  545. /**
  546. * Get order history cookie retention days.
  547. */
  548. function getOrderHistoryTtlDays(): int {
  549. $ttlDays = defined('ORDER_HISTORY_COOKIE_TTL_DAYS') ? (int) ORDER_HISTORY_COOKIE_TTL_DAYS : 365;
  550. return $ttlDays > 0 ? $ttlDays : 365;
  551. }
  552. /**
  553. * Get cookie path from SITE_URL.
  554. */
  555. function getOrderHistoryCookiePath(): string {
  556. $siteUrl = defined('SITE_URL') ? trim((string) SITE_URL) : '';
  557. if (strpos($siteUrl, '://') !== false) {
  558. $parsedPath = parse_url($siteUrl, PHP_URL_PATH);
  559. $siteUrl = is_string($parsedPath) ? $parsedPath : '';
  560. }
  561. if ($siteUrl === '' || $siteUrl === '/') {
  562. return '/';
  563. }
  564. if ($siteUrl[0] !== '/') {
  565. $siteUrl = '/' . $siteUrl;
  566. }
  567. return rtrim($siteUrl, '/');
  568. }
  569. /**
  570. * Detect whether current request uses HTTPS.
  571. */
  572. function isHttpsRequest(): bool {
  573. if (!empty($_SERVER['HTTPS']) && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
  574. return true;
  575. }
  576. if (isset($_SERVER['SERVER_PORT']) && (int) $_SERVER['SERVER_PORT'] === 443) {
  577. return true;
  578. }
  579. if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower((string) $_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
  580. return true;
  581. }
  582. return false;
  583. }
  584. /**
  585. * Check if reservation is hidden (spam/deleted)
  586. */
  587. function isReservationHidden($reservation) {
  588. return isset($reservation['is_hidden']) && $reservation['is_hidden'] === true;
  589. }
  590. /**
  591. * Save reservations
  592. */
  593. function saveReservations($reservations) {
  594. $data = ['reservations' => $reservations];
  595. writeJsonFile(RESERVATIONS_FILE, $data);
  596. }
  597. /**
  598. * Generate order number
  599. * Pattern: PREFIX-YEAR-SEQ
  600. */
  601. function generateReservationId() {
  602. $reservations = getReservations();
  603. $year = date('Y');
  604. $prefix = defined('ORDER_PREFIX') ? ORDER_PREFIX : 'ORD';
  605. $max = 0;
  606. $pattern = '/^' . preg_quote($prefix, '/') . '-\\d{4}-(\\d+)$/';
  607. foreach ($reservations as $reservation) {
  608. if (!isset($reservation['id'])) {
  609. continue;
  610. }
  611. if (preg_match($pattern, $reservation['id'], $matches)) {
  612. $num = (int)$matches[1];
  613. if ($num > $max) {
  614. $max = $num;
  615. }
  616. }
  617. }
  618. $next = $max + 1;
  619. return sprintf('%s-%s-%03d', $prefix, $year, $next);
  620. }
  621. /**
  622. * Check if product has enough stock
  623. * For apparel: checks stock for specific size
  624. * For merch: checks general stock
  625. */
  626. function checkStock($productId, $quantity, $size = null) {
  627. $product = getProductById($productId);
  628. if (!$product) {
  629. return false;
  630. }
  631. // For apparel with sizes, check stock per size
  632. if ($product['category'] === 'apparel' && !empty($product['sizes']) && $size !== null) {
  633. if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
  634. return false;
  635. }
  636. $sizeStock = isset($product['stock_by_size'][$size]) ? (int)$product['stock_by_size'][$size] : 0;
  637. return $sizeStock >= $quantity;
  638. }
  639. // For merch or apparel without size-specific stock, use general stock
  640. $stock = isset($product['stock']) ? (int)$product['stock'] : 0;
  641. return $stock >= $quantity;
  642. }
  643. /**
  644. * Get stock for a product (per size for apparel, general for merch)
  645. */
  646. function getStock($product, $size = null) {
  647. if ($product['category'] === 'apparel' && !empty($product['sizes']) && $size !== null) {
  648. if (isset($product['stock_by_size']) && is_array($product['stock_by_size'])) {
  649. return isset($product['stock_by_size'][$size]) ? (int)$product['stock_by_size'][$size] : 0;
  650. }
  651. }
  652. return isset($product['stock']) ? (int)$product['stock'] : 0;
  653. }
  654. /**
  655. * Get total stock for a product (sum of all sizes for apparel)
  656. */
  657. function getTotalStock($product) {
  658. if ($product['category'] === 'apparel' && isset($product['stock_by_size']) && is_array($product['stock_by_size'])) {
  659. return array_sum($product['stock_by_size']);
  660. }
  661. return isset($product['stock']) ? (int)$product['stock'] : 0;
  662. }
  663. /**
  664. * Allocate stock for reservation
  665. */
  666. function allocateStock($productId, $quantity, $size = null) {
  667. $products = getProducts();
  668. foreach ($products as &$product) {
  669. if ($product['id'] == $productId) {
  670. // For apparel with sizes, allocate per size
  671. if ($product['category'] === 'apparel' && !empty($product['sizes']) && $size !== null) {
  672. if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
  673. $product['stock_by_size'] = [];
  674. }
  675. if (!isset($product['stock_by_size'][$size])) {
  676. $product['stock_by_size'][$size] = 0;
  677. }
  678. $product['stock_by_size'][$size] -= $quantity;
  679. if ($product['stock_by_size'][$size] < 0) {
  680. $product['stock_by_size'][$size] = 0;
  681. }
  682. } else {
  683. // For merch or general stock
  684. if (!isset($product['stock'])) {
  685. $product['stock'] = 0;
  686. }
  687. $product['stock'] -= $quantity;
  688. if ($product['stock'] < 0) {
  689. $product['stock'] = 0;
  690. }
  691. }
  692. break;
  693. }
  694. }
  695. saveProducts($products);
  696. }
  697. /**
  698. * Release stock from reservation
  699. */
  700. function releaseStock($productId, $quantity, $size = null) {
  701. $products = getProducts();
  702. foreach ($products as &$product) {
  703. if ($product['id'] == $productId) {
  704. // For apparel with sizes, release per size
  705. if ($product['category'] === 'apparel' && !empty($product['sizes']) && $size !== null) {
  706. if (!isset($product['stock_by_size']) || !is_array($product['stock_by_size'])) {
  707. $product['stock_by_size'] = [];
  708. }
  709. if (!isset($product['stock_by_size'][$size])) {
  710. $product['stock_by_size'][$size] = 0;
  711. }
  712. $product['stock_by_size'][$size] += $quantity;
  713. } else {
  714. // For merch or general stock
  715. if (!isset($product['stock'])) {
  716. $product['stock'] = 0;
  717. }
  718. $product['stock'] += $quantity;
  719. }
  720. break;
  721. }
  722. }
  723. saveProducts($products);
  724. }
  725. /**
  726. * Create new reservation
  727. */
  728. function createReservation($customerName, $customerEmail, $items) {
  729. $reservations = getReservations();
  730. // Validate stock for all items
  731. foreach ($items as $item) {
  732. $size = isset($item['size']) ? $item['size'] : null;
  733. if (!checkStock($item['product_id'], $item['quantity'], $size)) {
  734. $product = getProductById($item['product_id']);
  735. $productName = $product ? $product['name'] : 'Produkt';
  736. $sizeInfo = $size ? " (Größe: $size)" : '';
  737. return ['success' => false, 'message' => "Nicht genügend Lagerbestand für: $productName$sizeInfo"];
  738. }
  739. }
  740. // Allocate stock
  741. foreach ($items as $item) {
  742. $size = isset($item['size']) ? $item['size'] : null;
  743. allocateStock($item['product_id'], $item['quantity'], $size);
  744. }
  745. // Create reservation
  746. $now = new DateTime();
  747. $expires = clone $now;
  748. $expires->modify('+' . RESERVATION_EXPIRY_DAYS . ' days');
  749. $reservation = [
  750. 'id' => generateReservationId(),
  751. 'customer_name' => $customerName,
  752. 'customer_email' => $customerEmail,
  753. 'items' => $items,
  754. 'created' => $now->format('Y-m-d H:i:s'),
  755. 'expires' => $expires->format('Y-m-d H:i:s'),
  756. 'status' => 'open',
  757. 'picked_up' => false,
  758. 'type' => 'regular',
  759. 'is_hidden' => false
  760. ];
  761. $reservations[] = $reservation;
  762. saveReservations($reservations);
  763. // Send confirmation emails
  764. sendReservationEmails($reservation);
  765. return ['success' => true, 'reservation' => $reservation];
  766. }
  767. /**
  768. * Create new backorder reservation
  769. */
  770. function createBackorderReservation($customerName, $customerEmail, $items) {
  771. $reservations = getReservations();
  772. $now = new DateTime();
  773. $reservation = [
  774. 'id' => generateReservationId(),
  775. 'customer_name' => $customerName,
  776. 'customer_email' => $customerEmail,
  777. 'items' => $items,
  778. 'created' => $now->format('Y-m-d H:i:s'),
  779. 'expires' => '',
  780. 'status' => 'open',
  781. 'picked_up' => false,
  782. 'type' => 'backorder',
  783. 'backorder_status' => 'pending',
  784. 'is_hidden' => false
  785. ];
  786. $reservations[] = $reservation;
  787. saveReservations($reservations);
  788. // Send confirmation emails
  789. sendBackorderEmails($reservation);
  790. return ['success' => true, 'reservation' => $reservation];
  791. }
  792. /**
  793. * Mark reservation as picked up
  794. */
  795. function markReservationPickedUp($reservationId) {
  796. $reservations = getReservations();
  797. foreach ($reservations as &$reservation) {
  798. if ($reservation['id'] === $reservationId) {
  799. if (isReservationHidden($reservation)) {
  800. break;
  801. }
  802. $reservation['picked_up'] = true;
  803. $reservation['status'] = 'picked_up';
  804. break;
  805. }
  806. }
  807. saveReservations($reservations);
  808. }
  809. /**
  810. * Mark reservation/backorder as spam/deleted and hide it from non-admin views.
  811. * For open regular reservations we release stock, because the order is discarded.
  812. */
  813. function markReservationHidden($reservationId) {
  814. $reservations = getReservations();
  815. foreach ($reservations as &$reservation) {
  816. if ($reservation['id'] !== $reservationId) {
  817. continue;
  818. }
  819. if (isReservationHidden($reservation)) {
  820. return ['success' => false, 'message' => 'Bestellung ist bereits als Spam/Gelöscht markiert.'];
  821. }
  822. $isBackorder = isset($reservation['type']) && $reservation['type'] === 'backorder';
  823. if (!$isBackorder && isset($reservation['status']) && $reservation['status'] === 'open' && empty($reservation['picked_up'])) {
  824. foreach ($reservation['items'] as $item) {
  825. $size = isset($item['size']) ? $item['size'] : null;
  826. releaseStock($item['product_id'], $item['quantity'], $size);
  827. }
  828. $reservation['status'] = 'deleted';
  829. }
  830. $reservation['is_hidden'] = true;
  831. $reservation['hidden_at'] = date('Y-m-d H:i:s');
  832. $reservation['hidden_reason'] = 'spam_deleted';
  833. saveReservations($reservations);
  834. return ['success' => true];
  835. }
  836. return ['success' => false, 'message' => 'Bestellung nicht gefunden.'];
  837. }
  838. /**
  839. * Check and expire old reservations
  840. */
  841. function expireOldReservations() {
  842. $reservations = getReservations();
  843. $now = new DateTime();
  844. $changed = false;
  845. foreach ($reservations as &$reservation) {
  846. if (isReservationHidden($reservation)) {
  847. continue;
  848. }
  849. if ($reservation['status'] === 'open' && !$reservation['picked_up']) {
  850. if (isset($reservation['type']) && $reservation['type'] === 'backorder') {
  851. continue;
  852. }
  853. if (empty($reservation['expires'])) {
  854. continue;
  855. }
  856. $expires = new DateTime($reservation['expires']);
  857. if ($now > $expires) {
  858. $reservation['status'] = 'expired';
  859. // Release stock
  860. foreach ($reservation['items'] as $item) {
  861. $size = isset($item['size']) ? $item['size'] : null;
  862. releaseStock($item['product_id'], $item['quantity'], $size);
  863. }
  864. $changed = true;
  865. }
  866. }
  867. }
  868. if ($changed) {
  869. saveReservations($reservations);
  870. }
  871. }
  872. /**
  873. * Check if all items are in stock
  874. */
  875. function canFulfillReservationItems($items) {
  876. foreach ($items as $item) {
  877. $size = isset($item['size']) ? $item['size'] : null;
  878. if (!checkStock($item['product_id'], $item['quantity'], $size)) {
  879. return false;
  880. }
  881. }
  882. return true;
  883. }
  884. /**
  885. * Mark backorder as available
  886. */
  887. function markBackorderAvailable($reservationId) {
  888. $reservations = getReservations();
  889. foreach ($reservations as &$reservation) {
  890. if ($reservation['id'] === $reservationId) {
  891. if (isReservationHidden($reservation)) {
  892. return ['success' => false, 'message' => 'Diese Vorbestellung ist als Spam/Gelöscht markiert.'];
  893. }
  894. if (!isset($reservation['type']) || $reservation['type'] !== 'backorder') {
  895. return ['success' => false, 'message' => 'Diese Vorbestellung wurde bereits in eine Bestellung umgewandelt.'];
  896. }
  897. if (isset($reservation['backorder_status']) && $reservation['backorder_status'] === 'notified') {
  898. return ['success' => false, 'message' => 'Diese Vorbestellung wurde bereits informiert.'];
  899. }
  900. if (!canFulfillReservationItems($reservation['items'])) {
  901. return ['success' => false, 'message' => 'Nicht alle Artikel sind verfügbar.'];
  902. }
  903. foreach ($reservation['items'] as $item) {
  904. $size = isset($item['size']) ? $item['size'] : null;
  905. allocateStock($item['product_id'], $item['quantity'], $size);
  906. }
  907. $now = new DateTime();
  908. $expires = clone $now;
  909. $expires->modify('+' . RESERVATION_EXPIRY_DAYS . ' days');
  910. $reservation['type'] = 'regular';
  911. $reservation['status'] = 'open';
  912. $reservation['picked_up'] = false;
  913. $reservation['expires'] = $expires->format('Y-m-d H:i:s');
  914. if (isset($reservation['backorder_status'])) {
  915. unset($reservation['backorder_status']);
  916. }
  917. saveReservations($reservations);
  918. sendBackorderAvailableEmail($reservation);
  919. return ['success' => true, 'reservation' => $reservation];
  920. }
  921. }
  922. return ['success' => false, 'message' => 'Vorbestellung nicht gefunden.'];
  923. }
  924. /**
  925. * Sanitize input
  926. */
  927. function sanitize($input) {
  928. return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8');
  929. }
  930. /**
  931. * Format price
  932. */
  933. function formatPrice($price) {
  934. return number_format($price, 2, ',', '.') . ' €';
  935. }
  936. /**
  937. * Format date
  938. */
  939. function formatDate($dateString) {
  940. $date = new DateTime($dateString);
  941. return $date->format('d.m.Y H:i');
  942. }
  943. /**
  944. * Send email
  945. */
  946. function sendEmail($to, $subject, $message, $isHtml = true) {
  947. $headers = [];
  948. $headers[] = 'From: ' . FROM_NAME . ' <' . FROM_EMAIL . '>';
  949. $headers[] = 'Reply-To: ' . FROM_EMAIL;
  950. $headers[] = 'X-Mailer: PHP/' . phpversion();
  951. if ($isHtml) {
  952. $headers[] = 'MIME-Version: 1.0';
  953. $headers[] = 'Content-type: text/html; charset=UTF-8';
  954. }
  955. return mail($to, $subject, $message, implode("\r\n", $headers));
  956. }
  957. /**
  958. * Get all admin notification recipients from admin accounts.
  959. * Falls back to ADMIN_EMAIL if no account email is configured.
  960. */
  961. function getAdminNotificationEmails() {
  962. $accounts = getAdminAccounts();
  963. $emails = [];
  964. foreach ($accounts as $account) {
  965. if (!isset($account['email'])) {
  966. continue;
  967. }
  968. $email = normalizeAdminEmail($account['email']);
  969. if (!isValidAdminEmail($email)) {
  970. continue;
  971. }
  972. $emails[] = $email;
  973. }
  974. if (empty($emails)) {
  975. $fallbackEmail = getDefaultAdminEmail();
  976. if ($fallbackEmail !== '') {
  977. $emails[] = $fallbackEmail;
  978. }
  979. }
  980. return array_values(array_unique($emails));
  981. }
  982. /**
  983. * Send admin notifications to all configured recipients.
  984. */
  985. function sendAdminNotificationEmails($subject, $message, $isHtml = true) {
  986. $emails = getAdminNotificationEmails();
  987. if (empty($emails)) {
  988. return false;
  989. }
  990. $sent = false;
  991. foreach ($emails as $email) {
  992. $result = sendEmail($email, $subject, $message, $isHtml);
  993. if ($result) {
  994. $sent = true;
  995. }
  996. }
  997. return $sent;
  998. }
  999. /**
  1000. * Send reservation confirmation emails
  1001. */
  1002. function sendReservationEmails($reservation) {
  1003. $products = getProducts();
  1004. // Build items list
  1005. $itemsHtml = '<ul style="list-style: none; margin: 0; padding: 0;">';
  1006. foreach ($reservation['items'] as $item) {
  1007. $product = getProductById($item['product_id']);
  1008. if ($product) {
  1009. $sizeInfo = '';
  1010. if (isset($item['size']) && !empty($item['size'])) {
  1011. $sizeInfo = ' - Größe: ' . htmlspecialchars($item['size']);
  1012. }
  1013. $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>';
  1014. }
  1015. }
  1016. $itemsHtml .= '</ul>';
  1017. // Customer email
  1018. $customerSubject = 'Ihre Reservierung bei ' . SITE_NAME;
  1019. $customerMessage = '
  1020. <html>
  1021. <head>
  1022. <meta charset="UTF-8">
  1023. </head>
  1024. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1025. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1026. <h2 style="color: #cac300; margin-top: 0;">Reservierung bestätigt</h2>
  1027. <p>Sehr geehrte/r ' . htmlspecialchars($reservation['customer_name']) . ',</p>
  1028. <p>vielen Dank für Ihre Reservierung bei ' . SITE_NAME . '.</p>
  1029. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px; text-align: center;">
  1030. <h3 style="margin-top: 0; color: #f5f7fb;">Ihre Bestellnummer:</h3>
  1031. <p style="margin: 0; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</p>
  1032. </div>
  1033. <h3>Reservierungsdetails:</h3>
  1034. <p><strong>Bestellnummer:</strong> ' . htmlspecialchars($reservation['id']) . '</p>
  1035. <p><strong>Erstellt am:</strong> ' . formatDate($reservation['created']) . '</p>
  1036. <p><strong>Gültig bis:</strong> ' . formatDate($reservation['expires']) . '</p>
  1037. <h3>Reservierte Artikel:</h3>
  1038. <div style="background: #303745; border: 1px solid #4a5263; border-left: 4px solid #cac300; border-radius: 8px; padding: 1rem;">' . $itemsHtml . '</div>
  1039. <p><strong>Wichtig:</strong> Bitte nennen Sie diese Bestellnummer bei der Abholung. Die Reservierung ist bis zum ' . formatDate($reservation['expires']) . ' gültig.</p>
  1040. <p>Mit freundlichen Grüßen<br>' . SITE_NAME . '</p>
  1041. </div>
  1042. </body>
  1043. </html>';
  1044. sendEmail($reservation['customer_email'], $customerSubject, $customerMessage);
  1045. // Admin email
  1046. $adminSubject = 'Neue Reservierung: ' . $reservation['id'];
  1047. $adminMessage = '
  1048. <html>
  1049. <head>
  1050. <meta charset="UTF-8">
  1051. </head>
  1052. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1053. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1054. <h2 style="color: #cac300; margin-top: 0;">Neue Reservierung</h2>
  1055. <p>Eine neue Reservierung wurde erstellt:</p>
  1056. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px;">
  1057. <h3 style="margin-top: 0;">Bestellnummer:</h3>
  1058. <p style="margin: 0; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</p>
  1059. </div>
  1060. <h3>Kundendaten:</h3>
  1061. <p><strong>Name:</strong> ' . htmlspecialchars($reservation['customer_name']) . '</p>
  1062. <p><strong>E-Mail:</strong> ' . htmlspecialchars($reservation['customer_email']) . '</p>
  1063. <h3>Reservierungsdetails:</h3>
  1064. <p><strong>Bestellnummer:</strong> ' . htmlspecialchars($reservation['id']) . '</p>
  1065. <p><strong>Erstellt am:</strong> ' . formatDate($reservation['created']) . '</p>
  1066. <p><strong>Gültig bis:</strong> ' . formatDate($reservation['expires']) . '</p>
  1067. <h3>Reservierte Artikel:</h3>
  1068. <div style="background: #303745; border: 1px solid #4a5263; border-left: 4px solid #cac300; border-radius: 8px; padding: 1rem;">' . $itemsHtml . '</div>
  1069. </div>
  1070. </body>
  1071. </html>';
  1072. sendAdminNotificationEmails($adminSubject, $adminMessage);
  1073. }
  1074. /**
  1075. * Send backorder confirmation emails
  1076. */
  1077. function sendBackorderEmails($reservation) {
  1078. // Build items list
  1079. $itemsHtml = '<ul style="list-style: none; margin: 0; padding: 0;">';
  1080. foreach ($reservation['items'] as $item) {
  1081. $product = getProductById($item['product_id']);
  1082. if ($product) {
  1083. $sizeInfo = '';
  1084. if (isset($item['size']) && !empty($item['size'])) {
  1085. $sizeInfo = ' - Größe: ' . htmlspecialchars($item['size']);
  1086. }
  1087. $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>';
  1088. }
  1089. }
  1090. $itemsHtml .= '</ul>';
  1091. // Customer email
  1092. $customerSubject = 'Vorbestellung bei ' . SITE_NAME;
  1093. $customerMessage = '
  1094. <html>
  1095. <head>
  1096. <meta charset="UTF-8">
  1097. </head>
  1098. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1099. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1100. <h2 style="color: #cac300; margin-top: 0;">Vorbestellung bestätigt</h2>
  1101. <p>Sehr geehrte/r ' . htmlspecialchars($reservation['customer_name']) . ',</p>
  1102. <p>vielen Dank für Ihre Vorbestellung bei ' . SITE_NAME . '.</p>
  1103. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px; text-align: center;">
  1104. <h3 style="margin-top: 0; color: #f5f7fb;">Ihre Bestellnummer:</h3>
  1105. <p style="margin: 0; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</p>
  1106. </div>
  1107. <h3>Vorbestellungsdetails:</h3>
  1108. <p><strong>Bestellnummer:</strong> ' . htmlspecialchars($reservation['id']) . '</p>
  1109. <p><strong>Erstellt am:</strong> ' . formatDate($reservation['created']) . '</p>
  1110. <h3>Vorbestellte Artikel:</h3>
  1111. <div style="background: #303745; border: 1px solid #4a5263; border-left: 4px solid #cac300; border-radius: 8px; padding: 1rem;">' . $itemsHtml . '</div>
  1112. <div style="background: #28292a; border: 2px solid #cf2e2e; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px;">
  1113. <strong>Hinweis:</strong> Die Lieferzeiten sind nicht bekannt, da die Bestellung in Chargen erfolgt.
  1114. </div>
  1115. <p>Wir informieren Sie, sobald die komplette Vorbestellung zur Abholung bereit ist.</p>
  1116. <p>Mit freundlichen Grüßen<br>' . SITE_NAME . '</p>
  1117. </div>
  1118. </body>
  1119. </html>';
  1120. sendEmail($reservation['customer_email'], $customerSubject, $customerMessage);
  1121. // Admin email
  1122. $adminSubject = 'Neue Vorbestellung: ' . $reservation['id'];
  1123. $adminMessage = '
  1124. <html>
  1125. <head>
  1126. <meta charset="UTF-8">
  1127. </head>
  1128. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1129. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1130. <h2 style="color: #cac300; margin-top: 0;">Neue Vorbestellung</h2>
  1131. <p>Eine neue Vorbestellung wurde erstellt:</p>
  1132. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px;">
  1133. <h3 style="margin-top: 0;">Bestellnummer:</h3>
  1134. <p style="margin: 0; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</p>
  1135. </div>
  1136. <h3>Kundendaten:</h3>
  1137. <p><strong>Name:</strong> ' . htmlspecialchars($reservation['customer_name']) . '</p>
  1138. <p><strong>E-Mail:</strong> ' . htmlspecialchars($reservation['customer_email']) . '</p>
  1139. <h3>Vorbestellungsdetails:</h3>
  1140. <p><strong>Bestellnummer:</strong> ' . htmlspecialchars($reservation['id']) . '</p>
  1141. <p><strong>Erstellt am:</strong> ' . formatDate($reservation['created']) . '</p>
  1142. <h3>Vorbestellte Artikel:</h3>
  1143. <div style="background: #303745; border: 1px solid #4a5263; border-left: 4px solid #cac300; border-radius: 8px; padding: 1rem;">' . $itemsHtml . '</div>
  1144. </div>
  1145. </body>
  1146. </html>';
  1147. sendAdminNotificationEmails($adminSubject, $adminMessage);
  1148. }
  1149. /**
  1150. * Send backorder availability email
  1151. */
  1152. function sendBackorderAvailableEmail($reservation) {
  1153. $itemsHtml = '<ul>';
  1154. foreach ($reservation['items'] as $item) {
  1155. $product = getProductById($item['product_id']);
  1156. if ($product) {
  1157. $sizeInfo = '';
  1158. if (isset($item['size']) && !empty($item['size'])) {
  1159. $sizeInfo = ' - Größe: ' . htmlspecialchars($item['size']);
  1160. }
  1161. $itemsHtml .= '<li>' . htmlspecialchars($product['name']) . $sizeInfo . ' - Menge: ' . $item['quantity'] . '</li>';
  1162. }
  1163. }
  1164. $itemsHtml .= '</ul>';
  1165. $subject = 'Ihre Vorbestellung ist zur Abholung bereit';
  1166. $message = '
  1167. <html>
  1168. <head>
  1169. <meta charset="UTF-8">
  1170. </head>
  1171. <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #f5f7fb; background: #28292a; padding: 1.5rem;">
  1172. <div style="max-width: 640px; margin: 0 auto; background: #2f3541; padding: 1.5rem 2rem; border-radius: 10px; border: 1px solid #3b4252;">
  1173. <h2 style="color: #cac300; margin-top: 0;">Vorbestellung zur Abholung bereit</h2>
  1174. <p>Sehr geehrte/r ' . htmlspecialchars($reservation['customer_name']) . ',</p>
  1175. <p>Ihre komplette Vorbestellung ist jetzt zur Abholung bereit.</p>
  1176. <div style="background: #28292a; border: 2px solid #cac300; padding: 1.5rem; margin: 1.5rem 0; border-radius: 8px; text-align: center;">
  1177. <h3 style="margin-top: 0; color: #f5f7fb;">Ihre Bestellnummer:</h3>
  1178. <h2 style="font-size: 2rem; letter-spacing: 0.2rem; color: #cac300; font-family: monospace;">' . htmlspecialchars($reservation['id']) . '</h2>
  1179. </div>
  1180. <h3>Bereitliegende Artikel:</h3>
  1181. ' . $itemsHtml . '
  1182. <p>Bitte nennen Sie die Bestellnummer bei der Abholung.</p>
  1183. <p>Mit freundlichen Grüßen<br>' . SITE_NAME . '</p>
  1184. </div>
  1185. </body>
  1186. </html>';
  1187. sendEmail($reservation['customer_email'], $subject, $message);
  1188. }