WebOrbiton
v1.0.0.0

StocketBase

798 lines · 32.5 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/database.php';
  6. require_once __DIR__ . '/icons.php';
  7. require_once __DIR__ . '/social-icons.php';
  8. require_once __DIR__ . '/product-scheduler.php';
  9. require_once __DIR__ . '/languages.php';
  10. ​
  11. final class SiteFront
  12. {
  13. private static ?array $settings = null;
  14. ​
  15. public const AD_PLACEMENTS = [
  16. 'header' => 'Header (below navigation, all pages)',
  17. 'homepage-top' => 'Homepage — top (below hero)',
  18. 'catalog-top' => 'Catalog grid — between cards (homepage & category)',
  19. 'product-page' => 'Product page — after gallery, before description',
  20. 'checkout' => 'Checkout page',
  21. 'footer' => 'Footer (above footer)',
  22. ];
  23. ​
  24. public static function productUrl(string $slug, ?string $lang = null): string
  25. {
  26. $basePath = rtrim((string) Config::get('APP_BASE_PATH', ''), '/');
  27. $prefix = $lang === null ? Languages::prefix() : ($lang === '' ? '' : '/' . $lang);
  28. if ((self::settings()['clean_product_urls'] ?? '0') === '1') {
  29. return $basePath . $prefix . '/' . rawurlencode($slug);
  30. }
  31. ​
  32. return $basePath . $prefix . '/product.php?slug=' . rawurlencode($slug);
  33. }
  34. ​
  35. public static function resetSettings(): void
  36. {
  37. self::$settings = null;
  38. }
  39. ​
  40. public static function settings(): array
  41. {
  42. if (self::$settings !== null) {
  43. return self::$settings;
  44. }
  45. ​
  46. $defaults = [
  47. 'site_name' => Config::get('APP_NAME', 'StocketBase'),
  48. 'site_description' => '',
  49. 'seo_title' => '',
  50. 'seo_description' => '',
  51. 'seo_keywords' => '',
  52. 'seo_author' => '',
  53. 'seo_reply_to' => '',
  54. 'seo_application_name' => '',
  55. 'seo_robots_index' => '1',
  56. 'seo_robots_follow' => '1',
  57. 'seo_og_product_image_enabled' => '0',
  58. 'llms_txt_enabled' => '0',
  59. 'site_logo_url' => '',
  60. 'site_show_name_with_logo' => '0',
  61. 'site_short_name' => '',
  62. 'site_short_name_enabled' => '0',
  63. 'registration_enabled' => '1',
  64. 'login_enabled' => '1',
  65. 'require_account_to_purchase' => '0',
  66. 'download_link_expiry_days' => '30',
  67. 'download_link_max_downloads' => '10',
  68. 'contact_enabled' => '0',
  69. 'contact_recipient_email' => '',
  70. 'contact_provider' => 'php_mail',
  71. 'contact_subject_mode' => 'free',
  72. 'contact_subjects' => '',
  73. 'contact_from_email' => '',
  74. 'contact_resend_api_key' => '',
  75. 'contact_postmark_token' => '',
  76. 'custom_css' => '',
  77. 'custom_js' => '',
  78. 'custom_head_html' => '',
  79. 'custom_head_html_index' => '0',
  80. 'custom_head_html_product' => '0',
  81. 'custom_head_html_category' => '0',
  82. 'custom_head_html_pages' => '0',
  83. 'custom_body_html' => '',
  84. 'custom_body_html_index' => '0',
  85. 'custom_body_html_product' => '0',
  86. 'custom_body_html_category' => '0',
  87. 'custom_body_html_pages' => '0',
  88. 'menu_position' => 'top',
  89. 'featured_banner_enabled' => '0',
  90. 'featured_banner_mode' => 'static',
  91. 'featured_banner_hide_duplicates' => '0',
  92. 'featured_banner_product_1' => '0',
  93. 'featured_banner_product_2' => '0',
  94. 'featured_banner_product_3' => '0',
  95. 'featured_banner_product_4' => '0',
  96. 'featured_banner_product_5' => '0',
  97. 'featured_banner_auto_window_value' => '24',
  98. 'featured_banner_auto_window_unit' => 'hours',
  99. 'featured_banner_auto_category_mode' => 'mixed',
  100. 'related_products_enabled' => '1',
  101. 'product_show_buyers' => '0',
  102. 'product_show_downloads' => '0',
  103. 'back_to_top_enabled' => '0',
  104. 'back_to_top_progress_enabled' => '0',
  105. 'breadcrumbs_enabled' => '0',
  106. 'tags_enabled' => '0',
  107. 'shop_icon_choice' => 'cart',
  108. 'shop_icon_custom_svg' => '',
  109. 'activity_log_enabled' => '0',
  110. 'seo_preview_enabled' => '0',
  111. 'indexnow_enabled' => '0',
  112. 'indexnow_key' => '',
  113. 'sitemap_rss_enabled' => '1',
  114. 'reviews_enabled' => '1',
  115. 'consent_manager_enabled' => '0',
  116. 'consent_footer_icon_enabled' => '0',
  117. 'heading_font' => 'Plus Jakarta Sans',
  118. 'body_font' => 'PT Serif',
  119. 'interface_font' => 'Plus Jakarta Sans',
  120. 'store_category' => 'general',
  121. 'pricing_model' => 'per_product',
  122. 'store_currency' => 'USD',
  123. 'payment_provider' => 'stripe',
  124. 'payment_webhook_secret' => '',
  125. 'payment_polar_webhook_secret' => '',
  126. 'payment_stripe_secret_key' => '',
  127. 'payment_stripe_publishable_key' => '',
  128. 'payment_polar_access_token' => '',
  129. 'payment_polar_organization_id' => '',
  130. 'subscription_interval' => 'month',
  131. 'mail_transport' => 'smtp',
  132. 'mail_smtp_host' => '',
  133. 'mail_smtp_port' => '587',
  134. 'mail_smtp_username' => '',
  135. 'mail_smtp_password' => '',
  136. 'mail_smtp_encryption' => 'tls',
  137. 'mail_resend_api_key' => '',
  138. 'mail_postmark_server_token' => '',
  139. 'mail_from_email' => '',
  140. 'mail_from_name' => '',
  141. 'mail_reply_to' => '',
  142. 'footer_columns' => '[]',
  143. 'footer_social_links' => '[]',
  144. 'product_loading_mode' => 'scroll',
  145. 'pagination_style' => 'numbered',
  146. 'products_per_page_home' => '12',
  147. 'products_per_page_category' => '12',
  148. 'search_enabled' => '1',
  149. 'search_match_title' => '1',
  150. 'search_match_excerpt' => '1',
  151. 'search_match_content' => '0',
  152. 'search_style' => 'icon',
  153. 'search_show_in_nav' => '1',
  154. 'search_show_in_footer' => '0',
  155. 'share_enabled' => '0',
  156. 'share_facebook' => '1',
  157. 'share_twitter' => '1',
  158. 'share_linkedin' => '1',
  159. 'share_whatsapp' => '1',
  160. 'share_telegram' => '0',
  161. 'share_email' => '1',
  162. 'share_copy_link' => '1',
  163. 'pwa_enabled' => '0',
  164. 'pwa_name' => '',
  165. 'pwa_short_name' => '',
  166. 'pwa_description' => '',
  167. 'pwa_icon_url' => '',
  168. 'pwa_theme_color' => '#ffffff',
  169. 'pwa_background_color' => '#ffffff',
  170. 'app_banner_enabled' => '0',
  171. 'app_android_url' => '',
  172. 'app_ios_url' => '',
  173. 'multilang_enabled' => '0',
  174. 'multilang_default_code' => 'en',
  175. 'multilang_default_name' => 'English',
  176. 'multilang_switcher' => '1',
  177. ];
  178. ​
  179. $rows = Database::site()->query('SELECT setting_key, setting_value FROM site_settings')->fetchAll();
  180. $values = [];
  181. foreach ($rows as $row) {
  182. $values[$row['setting_key']] = $row['setting_value'];
  183. }
  184. ​
  185. self::$settings = array_merge($defaults, $values);
  186. self::$settings = Languages::applySiteTexts(self::$settings);
  187. ProductScheduler::publishDue();
  188. ​
  189. return self::$settings;
  190. }
  191. ​
  192. public static function navItems(): array
  193. {
  194. try {
  195. $rows = Database::site()->query(
  196. 'SELECT * FROM nav_items ORDER BY sort_order ASC, id ASC'
  197. )->fetchAll();
  198. } catch (PDOException $e) {
  199. return self::fallbackNavItems();
  200. }
  201. ​
  202. if (empty($rows)) {
  203. return self::fallbackNavItems();
  204. }
  205. ​
  206. $items = [];
  207. $navTitles = Languages::titles('nav', array_map('intval', array_column($rows, 'id')));
  208. foreach ($rows as $row) {
  209. if ($row['item_type'] === 'category') {
  210. $statement = Database::site()->prepare(
  211. 'SELECT id, name, slug FROM categories WHERE id = :id AND show_in_menu = 1 LIMIT 1'
  212. );
  213. $statement->execute(['id' => $row['ref_id']]);
  214. $category = $statement->fetch();
  215. if (!$category) {
  216. continue;
  217. }
  218. $category = Languages::categories([$category])[0];
  219. $items[] = [
  220. 'label' => $navTitles[(int) $row['id']] ?? ($row['label'] !== null && $row['label'] !== '' ? $row['label'] : $category['name']),
  221. 'url' => 'category.php?slug=' . urlencode($category['slug']),
  222. 'target' => '_self',
  223. 'icon_html' => self::navItemIconHtml($row['icon_key'] ?? null, $row['icon_svg'] ?? null),
  224. ];
  225. continue;
  226. }
  227. ​
  228. if ($row['item_type'] === 'page') {
  229. $statement = Database::site()->prepare(
  230. "SELECT id, title, slug FROM pages WHERE id = :id AND show_in_menu = 1 AND status = 'published' LIMIT 1"
  231. );
  232. $statement->execute(['id' => $row['ref_id']]);
  233. $page = $statement->fetch();
  234. if (!$page) {
  235. continue;
  236. }
  237. $page = Languages::pages([$page])[0];
  238. $items[] = [
  239. 'label' => $navTitles[(int) $row['id']] ?? ($row['label'] !== null && $row['label'] !== '' ? $row['label'] : $page['title']),
  240. 'url' => 'page.php?slug=' . urlencode($page['slug']),
  241. 'target' => '_self',
  242. 'icon_html' => self::navItemIconHtml($row['icon_key'] ?? null, $row['icon_svg'] ?? null),
  243. ];
  244. continue;
  245. }
  246. ​
  247. if ($row['item_type'] === 'custom') {
  248. if (empty($row['label']) || empty($row['url'])) {
  249. continue;
  250. }
  251. $items[] = [
  252. 'label' => $navTitles[(int) $row['id']] ?? $row['label'],
  253. 'url' => $row['url'],
  254. 'target' => $row['target'] === '_blank' ? '_blank' : '_self',
  255. 'icon_html' => self::navItemIconHtml($row['icon_key'] ?? null, $row['icon_svg'] ?? null),
  256. ];
  257. }
  258. }
  259. ​
  260. return $items;
  261. }
  262. ​
  263. public static function appendToCustomMenu(string $itemType, int $refId): void
  264. {
  265. if (!in_array($itemType, ['category', 'page'], true) || $refId <= 0) {
  266. return;
  267. }
  268. ​
  269. try {
  270. $db = Database::site();
  271. if ((int) $db->query('SELECT COUNT(*) FROM nav_items')->fetchColumn() === 0) {
  272. return;
  273. }
  274. ​
  275. $existing = $db->prepare('SELECT 1 FROM nav_items WHERE item_type = :type AND ref_id = :ref_id LIMIT 1');
  276. $existing->execute(['type' => $itemType, 'ref_id' => $refId]);
  277. if ($existing->fetchColumn()) {
  278. return;
  279. }
  280. ​
  281. $nextOrder = (int) $db->query('SELECT COALESCE(MAX(sort_order), -1) + 1 FROM nav_items')->fetchColumn();
  282. $db->prepare('INSERT INTO nav_items (item_type, ref_id, sort_order) VALUES (:type, :ref_id, :sort_order)')
  283. ->execute(['type' => $itemType, 'ref_id' => $refId, 'sort_order' => $nextOrder]);
  284. } catch (PDOException $exception) {
  285. error_log('StocketBase: could not add ' . $itemType . ' ' . $refId . ' to the menu: ' . $exception->getMessage());
  286. }
  287. }
  288. ​
  289. private static function navItemIconHtml(?string $iconKey, ?string $iconSvg): string
  290. {
  291. if ($iconKey === 'custom' && !empty($iconSvg)) {
  292. return '<span class="nav-item-icon" aria-hidden="true">' . $iconSvg . '</span>';
  293. }
  294. ​
  295. if (!empty($iconKey) && Icons::isNavIcon($iconKey)) {
  296. return Icons::icon($iconKey, 'icon icon-sm nav-item-icon');
  297. }
  298. ​
  299. return '';
  300. }
  301. ​
  302. private static function fallbackNavItems(): array
  303. {
  304. $items = [];
  305. ​
  306. $categories = Database::site()->query(
  307. 'SELECT id, name, slug FROM categories WHERE show_in_menu = 1 ORDER BY sort_order ASC, name ASC'
  308. )->fetchAll();
  309. $categories = Languages::categories($categories);
  310. foreach ($categories as $category) {
  311. $items[] = [
  312. 'label' => $category['name'],
  313. 'url' => 'category.php?slug=' . urlencode($category['slug']),
  314. 'target' => '_self',
  315. ];
  316. }
  317. ​
  318. try {
  319. $pages = Database::site()->query(
  320. "SELECT id, title, slug FROM pages WHERE show_in_menu = 1 AND status = 'published' ORDER BY sort_order ASC, title ASC"
  321. )->fetchAll();
  322. } catch (PDOException $e) {
  323. $pages = [];
  324. }
  325. $pages = Languages::pages($pages);
  326. foreach ($pages as $page) {
  327. $items[] = [
  328. 'label' => $page['title'],
  329. 'url' => 'page.php?slug=' . urlencode($page['slug']),
  330. 'target' => '_self',
  331. ];
  332. }
  333. ​
  334. return $items;
  335. }
  336. ​
  337. public static function renderSearchControl(string $context): string
  338. {
  339. $settings = self::settings();
  340. ​
  341. if ($settings['search_enabled'] !== '1') {
  342. return '';
  343. }
  344. ​
  345. $placementKey = $context === 'footer' ? 'search_show_in_footer' : 'search_show_in_nav';
  346. if (($settings[$placementKey] ?? '0') !== '1') {
  347. return '';
  348. }
  349. ​
  350. $searchLabel = Language::get('nav_search', 'Search');
  351. $searchPlaceholder = Language::get('search_placeholder', 'Search');
  352. $magnifierSvg = '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>';
  353. ​
  354. if ($settings['search_style'] === 'field') {
  355. return '<form action="search.php" method="get" class="site-search-form site-search-form-' . htmlspecialchars($context, ENT_QUOTES)
  356. . '" role="search">'
  357. . '<span class="site-search-form-icon">' . $magnifierSvg . '</span>'
  358. . '<input type="text" name="q" class="site-search-input" placeholder="' . htmlspecialchars($searchPlaceholder, ENT_QUOTES) . '" aria-label="' . htmlspecialchars($searchLabel, ENT_QUOTES) . '">'
  359. . '</form>';
  360. }
  361. ​
  362. return '<a href="search.php" class="site-search-icon-link" aria-label="' . htmlspecialchars($searchLabel, ENT_QUOTES) . '">' . $magnifierSvg . '</a>';
  363. }
  364. ​
  365. public static function renderShareControl(array $article): string
  366. {
  367. $settings = self::settings();
  368. if (($settings['share_enabled'] ?? '0') !== '1') {
  369. return '';
  370. }
  371. ​
  372. $url = self::currentUrl();
  373. $encodedUrl = urlencode($url);
  374. $encodedTitle = urlencode((string) ($article['title'] ?? ''));
  375. ​
  376. $platformDefs = [
  377. 'facebook' => [
  378. 'label' => Language::get('share_facebook', 'Facebook'),
  379. 'url' => 'https://www.facebook.com/sharer/sharer.php?u=' . $encodedUrl,
  380. 'svg' => SocialIcons::builtinSvg('facebook'),
  381. ],
  382. 'twitter' => [
  383. 'label' => Language::get('share_twitter', 'Twitter / X'),
  384. 'url' => 'https://twitter.com/intent/tweet?url=' . $encodedUrl . '&text=' . $encodedTitle,
  385. 'svg' => SocialIcons::builtinSvg('twitter'),
  386. ],
  387. 'linkedin' => [
  388. 'label' => Language::get('share_linkedin', 'LinkedIn'),
  389. 'url' => 'https://www.linkedin.com/sharing/share-offsite/?url=' . $encodedUrl,
  390. 'svg' => SocialIcons::builtinSvg('linkedin'),
  391. ],
  392. 'whatsapp' => [
  393. 'label' => Language::get('share_whatsapp', 'WhatsApp'),
  394. 'url' => 'https://wa.me/?text=' . $encodedTitle . '%20' . $encodedUrl,
  395. 'svg' => '<svg viewBox="0 0 24 24"><path d="M12.04 2c-5.52 0-10 4.48-10 10 0 1.77.46 3.5 1.34 5.02L2 22l5.13-1.35a9.96 9.96 0 0 0 4.91 1.29h.01c5.52 0 10-4.48 10-10S17.56 2 12.04 2zm5.87 14.24c-.25.7-1.45 1.34-2 1.42-.51.08-1.16.12-1.87-.12-.43-.14-.98-.32-1.69-.63-2.97-1.28-4.9-4.27-5.05-4.47-.15-.2-1.21-1.61-1.21-3.07s.76-2.18 1.03-2.48c.27-.3.59-.37.79-.37.2 0 .4 0 .57.01.18.01.43-.07.67.51.25.6.85 2.08.92 2.23.07.15.12.33.02.53-.1.2-.15.33-.3.5-.15.18-.31.4-.45.54-.15.15-.3.31-.13.61.17.3.77 1.27 1.65 2.06 1.13 1.01 2.09 1.32 2.39 1.47.3.15.48.13.65-.08.18-.2.75-.87.95-1.17.2-.3.4-.25.67-.15.27.1 1.73.82 2.03.97.3.15.5.22.57.35.08.13.08.73-.17 1.43z"/></svg>',
  396. ],
  397. 'telegram' => [
  398. 'label' => Language::get('share_telegram', 'Telegram'),
  399. 'url' => 'https://t.me/share/url?url=' . $encodedUrl . '&text=' . $encodedTitle,
  400. 'svg' => '<svg viewBox="0 0 24 24"><path d="M21.9 4.3 18.8 19.5c-.23 1.05-.85 1.3-1.72.81l-4.75-3.5-2.29 2.2c-.25.25-.47.47-.96.47l.34-4.85 8.8-7.95c.38-.34-.09-.53-.6-.19L6.9 12.6l-4.7-1.47c-1.02-.32-1.04-1.02.21-1.5L20.6 2.9c.85-.32 1.6.2 1.3 1.4z"/></svg>',
  401. ],
  402. 'email' => [
  403. 'label' => Language::get('share_email', 'Email'),
  404. 'url' => 'mailto:?subject=' . $encodedTitle . '&body=' . $encodedUrl,
  405. 'svg' => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M3 7l9 6 9-6"></path></svg>',
  406. ],
  407. ];
  408. ​
  409. $platforms = [];
  410. foreach ($platformDefs as $key => $platform) {
  411. if (($settings['share_' . $key] ?? '0') === '1') {
  412. $platforms[$key] = $platform;
  413. }
  414. }
  415. ​
  416. $copyLinkEnabled = ($settings['share_copy_link'] ?? '0') === '1';
  417. if (empty($platforms) && !$copyLinkEnabled) {
  418. return '';
  419. }
  420. ​
  421. $shareLabel = Language::get('article_share', 'Share');
  422. $shareSvg = '<svg class="icon icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"></circle><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="19" r="3"></circle><line x1="8.6" y1="10.6" x2="15.4" y2="6.4"></line><line x1="8.6" y1="13.4" x2="15.4" y2="17.6"></line></svg>';
  423. ​
  424. $html = '<div class="article-share">';
  425. $html .= '<button type="button" class="share-toggle-btn" id="share-toggle-btn" aria-expanded="false" aria-haspopup="true">' . $shareSvg . '<span>' . htmlspecialchars($shareLabel) . '</span></button>';
  426. $html .= '<div class="share-panel" id="share-panel">';
  427. ​
  428. foreach ($platforms as $key => $platform) {
  429. $html .= '<a href="' . htmlspecialchars($platform['url'], ENT_QUOTES) . '" class="share-panel-item" target="_blank" rel="noopener noreferrer">'
  430. . '<span class="share-panel-item-icon share-panel-item-icon-' . htmlspecialchars($key, ENT_QUOTES) . '" aria-hidden="true">' . $platform['svg'] . '</span>'
  431. . '<span>' . htmlspecialchars($platform['label']) . '</span>'
  432. . '</a>';
  433. }
  434. ​
  435. if ($copyLinkEnabled) {
  436. $linkSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07l-1.42 1.41"></path><path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07l1.41-1.41"></path></svg>';
  437. $html .= '<button type="button" class="share-panel-item share-panel-item-copy" id="share-copy-btn" data-share-url="' . htmlspecialchars($url, ENT_QUOTES) . '" data-default-text="' . htmlspecialchars(Language::get('share_copy_link', 'Copy link'), ENT_QUOTES) . '" data-copied-text="' . htmlspecialchars(Language::get('share_link_copied', 'Link copied!'), ENT_QUOTES) . '">'
  438. . '<span class="share-panel-item-icon" aria-hidden="true">' . $linkSvg . '</span>'
  439. . '<span class="js-share-copy-label">' . htmlspecialchars(Language::get('share_copy_link', 'Copy link')) . '</span>'
  440. . '</button>';
  441. }
  442. ​
  443. $html .= '</div></div>';
  444. ​
  445. return $html;
  446. }
  447. ​
  448. public static function menuPages(): array
  449. {
  450. return Database::site()->query(
  451. "SELECT title, slug FROM pages WHERE status = 'published' AND show_in_menu = 1 ORDER BY sort_order ASC, title ASC"
  452. )->fetchAll();
  453. }
  454. ​
  455. public static function categories(): array
  456. {
  457. return Database::site()->query('SELECT id, name, slug FROM categories ORDER BY sort_order ASC, name ASC')->fetchAll();
  458. }
  459. ​
  460. public static function activeAds(string $placement): array
  461. {
  462. $statement = Database::site()->prepare(
  463. 'SELECT * FROM ads WHERE is_active = 1 AND placement = :placement ORDER BY created_at DESC'
  464. );
  465. $statement->execute(['placement' => $placement]);
  466. return $statement->fetchAll();
  467. }
  468. ​
  469. public static function renderAd(array $ad): string
  470. {
  471. switch ($ad['type']) {
  472. case 'static_text':
  473. $html = '<div class="ad-slot ad-text">' . nl2br(htmlspecialchars($ad['content'])) . '</div>';
  474. break;
  475. case 'static_image':
  476. $img = '<img src="' . htmlspecialchars($ad['content']) . '" alt="' . htmlspecialchars($ad['name']) . '">';
  477. $html = '<div class="ad-slot ad-image">' . ($ad['link_url'] ? '<a href="' . htmlspecialchars($ad['link_url']) . '" rel="sponsored noopener" target="_blank">' . $img . '</a>' : $img) . '</div>';
  478. break;
  479. case 'script':
  480. $html = '<div class="ad-slot ad-script" data-requires-consent="0">' . $ad['content'] . '</div>';
  481. break;
  482. default:
  483. $html = '';
  484. }
  485. ​
  486. return $html;
  487. }
  488. ​
  489. public static function activeAnalyticsScripts(string $placement): array
  490. {
  491. $statement = Database::site()->prepare(
  492. 'SELECT * FROM analytics_scripts WHERE is_active = 1 AND placement = :placement ORDER BY created_at ASC'
  493. );
  494. $statement->execute(['placement' => $placement]);
  495. return $statement->fetchAll();
  496. }
  497. ​
  498. public static function renderAnalyticsScripts(string $placement): string
  499. {
  500. $scripts = self::activeAnalyticsScripts($placement);
  501. $consentManagerEnabled = self::settings()['consent_manager_enabled'] === '1';
  502. $html = '';
  503. ​
  504. foreach ($scripts as $script) {
  505. $code = $script['script_code'];
  506. ​
  507. $looksLikeHtml = str_contains($code, '<script') || str_contains($code, '<style') || str_contains($code, '<link') || str_contains($code, '<noscript');
  508. ​
  509. if (!$looksLikeHtml) {
  510. $code = '<script>' . $code . '</script>';
  511. }
  512. ​
  513. if ($script['requires_consent'] && $consentManagerEnabled) {
  514. $safeCode = str_replace('</script>', '<\/script>', $code);
  515. $html .= '<script type="text/plain" class="consent-gated-script" data-placement="' . htmlspecialchars($placement) . '">' . $safeCode . '</script>';
  516. } else {
  517. $html .= $code;
  518. }
  519. }
  520. ​
  521. return $html;
  522. }
  523. ​
  524. public static function renderFontFaces(): string
  525. {
  526. require_once __DIR__ . '/font-library.php';
  527. ​
  528. $settings = self::settings();
  529. $emitted = [];
  530. $css = '';
  531. ​
  532. foreach ([$settings['heading_font'], $settings['body_font'], $settings['interface_font']] as $fontName) {
  533. if ($fontName === '' || in_array($fontName, $emitted, true)) {
  534. continue;
  535. }
  536. $css .= FontLibrary::fontFaceCss($fontName, $fontName);
  537. $emitted[] = $fontName;
  538. }
  539. ​
  540. return $css;
  541. }
  542. ​
  543. public static function fontVariablesCss(): string
  544. {
  545. $settings = self::settings();
  546. $headingFamily = addslashes($settings['heading_font']);
  547. $bodyFamily = addslashes($settings['body_font']);
  548. $interfaceFamily = addslashes($settings['interface_font']);
  549. ​
  550. return ":root{"
  551. . "--font-heading:\"{$headingFamily}\", -apple-system, BlinkMacSystemFont, sans-serif;"
  552. . "--font-body:\"{$bodyFamily}\", Georgia, serif;"
  553. . "--font-ui:\"{$interfaceFamily}\", -apple-system, BlinkMacSystemFont, sans-serif;"
  554. . "}";
  555. }
  556. ​
  557. private const TRACKING_PARAMS = ['fbclid', 'gclid', 'msclkid', 'yclid', 'igshid', 'mc_cid', 'mc_eid', '_ga', 'ref_src'];
  558. ​
  559. private static function configuredUrl(): string
  560. {
  561. $url = trim((string) Config::get('APP_URL', ''));
  562. if ($url === '') {
  563. return '';
  564. }
  565. ​
  566. if (!preg_match('#^[a-z][a-z0-9+.-]*://#i', $url)) {
  567. $url = 'https://' . $url;
  568. }
  569. ​
  570. return rtrim($url, '/');
  571. }
  572. ​
  573. private static function normalizeHost(string $host, string $scheme = ''): string
  574. {
  575. $host = strtolower(rtrim(trim($host), '.'));
  576. if ($host === '') {
  577. return '';
  578. }
  579. ​
  580. $port = '';
  581. if (preg_match('/^(.*):(\d+)$/', $host, $matches) && !str_ends_with($matches[1], ']')) {
  582. $host = $matches[1];
  583. $port = $matches[2];
  584. } elseif (preg_match('/^(\[.*\]):(\d+)$/', $host, $matches)) {
  585. $host = $matches[1];
  586. $port = $matches[2];
  587. }
  588. ​
  589. $defaultPorts = ['80', '443'];
  590. if ($scheme === 'http') {
  591. $defaultPorts = ['80'];
  592. } elseif ($scheme === 'https') {
  593. $defaultPorts = ['443'];
  594. }
  595. ​
  596. return in_array($port, $defaultPorts, true) || $port === '' ? $host : $host . ':' . $port;
  597. }
  598. ​
  599. private static function requestHost(): string
  600. {
  601. $forwarded = trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ''))[0]);
  602. $host = $forwarded !== '' ? $forwarded : (string) ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '');
  603. ​
  604. return self::normalizeHost($host);
  605. }
  606. ​
  607. private static function requestScheme(): string
  608. {
  609. $forwarded = strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0]));
  610. if ($forwarded === 'https' || $forwarded === 'http') {
  611. return $forwarded;
  612. }
  613. ​
  614. $https = strtolower((string) ($_SERVER['HTTPS'] ?? ''));
  615. if (($https !== '' && $https !== 'off') || (string) ($_SERVER['SERVER_PORT'] ?? '') === '443') {
  616. return 'https';
  617. }
  618. ​
  619. return 'http';
  620. }
  621. ​
  622. public static function isConfiguredHost(): bool
  623. {
  624. $configured = self::configuredUrl();
  625. if ($configured === '') {
  626. return true;
  627. }
  628. ​
  629. $parts = parse_url($configured);
  630. $configuredHost = self::normalizeHost((string) ($parts['host'] ?? '') . (isset($parts['port']) ? ':' . $parts['port'] : ''), (string) ($parts['scheme'] ?? ''));
  631. $currentHost = self::requestHost();
  632. ​
  633. if ($configuredHost === '' || $currentHost === '') {
  634. return true;
  635. }
  636. ​
  637. return $configuredHost === $currentHost;
  638. }
  639. ​
  640. private static function cleanRequestUri(): string
  641. {
  642. $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
  643. $path = (string) parse_url($uri, PHP_URL_PATH);
  644. $query = (string) parse_url($uri, PHP_URL_QUERY);
  645. ​
  646. if ($path === '') {
  647. $path = '/';
  648. }
  649. ​
  650. if ($query === '') {
  651. return $path;
  652. }
  653. ​
  654. $kept = [];
  655. foreach (explode('&', $query) as $pair) {
  656. if ($pair === '') {
  657. continue;
  658. }
  659. $name = strtolower(urldecode(explode('=', $pair, 2)[0]));
  660. if (str_starts_with($name, 'utm_') || in_array($name, self::TRACKING_PARAMS, true)) {
  661. continue;
  662. }
  663. $kept[] = $pair;
  664. }
  665. ​
  666. return $kept === [] ? $path : $path . '?' . implode('&', $kept);
  667. }
  668. ​
  669. public static function currentUrl(): string
  670. {
  671. $baseUrl = self::configuredUrl();
  672. if ($baseUrl === '') {
  673. $host = self::requestHost();
  674. $baseUrl = $host === '' ? '' : self::requestScheme() . '://' . $host;
  675. }
  676. ​
  677. return $baseUrl . self::cleanRequestUri();
  678. }
  679. ​
  680. public static function renderSeoMeta(): string
  681. {
  682. $settings = self::settings();
  683. $html = '';
  684. ​
  685. if ($settings['seo_keywords'] !== '') {
  686. $html .= '<meta name="keywords" content="' . htmlspecialchars($settings['seo_keywords'], ENT_QUOTES) . '">' . "\n";
  687. }
  688. ​
  689. if ($settings['seo_author'] !== '') {
  690. $html .= '<meta name="author" content="' . htmlspecialchars($settings['seo_author'], ENT_QUOTES) . '">' . "\n";
  691. }
  692. ​
  693. if ($settings['seo_reply_to'] !== '') {
  694. $html .= '<meta name="reply-to" content="' . htmlspecialchars($settings['seo_reply_to'], ENT_QUOTES) . '">' . "\n";
  695. }
  696. ​
  697. if ($settings['seo_application_name'] !== '') {
  698. $html .= '<meta name="application-name" content="' . htmlspecialchars($settings['seo_application_name'], ENT_QUOTES) . '">' . "\n";
  699. }
  700. ​
  701. $robotsIndex = $settings['seo_robots_index'] === '1' && self::isConfiguredHost() ? 'index' : 'noindex';
  702. $robotsFollow = $settings['seo_robots_follow'] === '1' ? 'follow' : 'nofollow';
  703. $html .= '<meta name="robots" content="' . $robotsIndex . ',' . $robotsFollow . '">' . "\n";
  704. ​
  705. $html .= '<link rel="canonical" href="' . htmlspecialchars(self::currentUrl(), ENT_QUOTES) . '">';
  706. ​
  707. return $html;
  708. }
  709. ​
  710. public static function renderOpenGraph(string $title, string $description, string $type = 'website', ?string $imagePath = null): string
  711. {
  712. $settings = self::settings();
  713. ​
  714. $html = '<meta property="og:title" content="' . htmlspecialchars($title, ENT_QUOTES) . '">' . "\n";
  715. $html .= '<meta property="og:type" content="' . htmlspecialchars($type, ENT_QUOTES) . '">' . "\n";
  716. $html .= '<meta property="og:url" content="' . htmlspecialchars(self::currentUrl(), ENT_QUOTES) . '">' . "\n";
  717. $html .= '<meta property="og:site_name" content="' . htmlspecialchars($settings['site_name'], ENT_QUOTES) . '">' . "\n";
  718. ​
  719. if ($description !== '') {
  720. $html .= '<meta property="og:description" content="' . htmlspecialchars($description, ENT_QUOTES) . '">' . "\n";
  721. }
  722. ​
  723. $ogImage = null;
  724. if ($imagePath !== null && $settings['seo_og_product_image_enabled'] === '1') {
  725. $ogImage = $imagePath;
  726. } elseif ($settings['site_logo_url'] !== '') {
  727. $ogImage = $settings['site_logo_url'];
  728. }
  729. ​
  730. if ($ogImage !== null && $ogImage !== '') {
  731. $html .= '<meta property="og:image" content="' . htmlspecialchars($ogImage, ENT_QUOTES) . '">';
  732. }
  733. ​
  734. return $html;
  735. }
  736. ​
  737. public static function renderOrganizationSchema(): string
  738. {
  739. $settings = self::settings();
  740. ​
  741. $schema = [
  742. '@context' => 'https://schema.org',
  743. '@graph' => [
  744. [
  745. '@type' => 'Organization',
  746. 'name' => $settings['site_name'],
  747. 'url' => rtrim(Config::get('APP_URL', ''), '/') . '/',
  748. ],
  749. [
  750. '@type' => 'WebSite',
  751. 'name' => $settings['site_name'],
  752. 'url' => rtrim(Config::get('APP_URL', ''), '/') . '/',
  753. ],
  754. ],
  755. ];
  756. ​
  757. if ($settings['site_logo_url'] !== '') {
  758. $schema['@graph'][0]['logo'] = $settings['site_logo_url'];
  759. }
  760. ​
  761. if ($settings['site_description'] !== '') {
  762. $schema['@graph'][1]['description'] = $settings['site_description'];
  763. }
  764. ​
  765. return '<script type="application/ld+json">' . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '</script>';
  766. }
  767. ​
  768. private static function isCustomHtmlEnabledFor(string $slot, ?string $pageType): bool
  769. {
  770. if ($pageType === null || !in_array($pageType, ['index', 'product', 'category', 'pages'], true)) {
  771. return false;
  772. }
  773. ​
  774. $settings = self::settings();
  775. $key = 'custom_' . $slot . '_html_' . $pageType;
  776. ​
  777. return ($settings[$key] ?? '0') === '1';
  778. }
  779. ​
  780. public static function renderCustomHeadHtml(?string $pageType): string
  781. {
  782. if (!self::isCustomHtmlEnabledFor('head', $pageType)) {
  783. return '';
  784. }
  785. ​
  786. return self::settings()['custom_head_html'];
  787. }
  788. ​
  789. public static function renderCustomBodyHtml(?string $pageType): string
  790. {
  791. if (!self::isCustomHtmlEnabledFor('body', $pageType)) {
  792. return '';
  793. }
  794. ​
  795. return self::settings()['custom_body_html'];
  796. }
  797. }
  798. ​