functions.php 40 KB

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