WebOrbiton
v1.0.0.0

StocketBase

1,745 lines · 102.3 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/auth.php';
  8. require_once __DIR__ . '/includes/csrf.php';
  9. require_once __DIR__ . '/includes/language.php';
  10. require_once __DIR__ . '/includes/font-library.php';
  11. require_once __DIR__ . '/includes/footer-links.php';
  12. require_once __DIR__ . '/includes/version.php';
  13. require_once __DIR__ . '/includes/ai.php';
  14. require_once __DIR__ . '/includes/currency.php';
  15. require_once __DIR__ . '/includes/mailer.php';
  16. ​
  17. Auth::boot();
  18. Auth::requireRoleAtLeast(Auth::ROLE_STORE_OWNER);
  19. ​
  20. $currentUser = Auth::user();
  21. $currentRole = Auth::role();
  22. $db = Database::site();
  23. $db->exec(
  24. 'CREATE TABLE IF NOT EXISTS site_settings (
  25. setting_key VARCHAR(120) NOT NULL PRIMARY KEY,
  26. setting_value LONGTEXT NULL,
  27. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  28. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
  29. );
  30. ​
  31. $flashMessage = null;
  32. $flashType = 'success';
  33. ​
  34. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  35. if (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  36. $flashMessage = 'Security check failed, please try again.';
  37. $flashType = 'error';
  38. } else {
  39. $action = $_POST['action'] ?? '';
  40. ​
  41. if ($action === 'save_settings') {
  42. $aiSaveError = null;
  43. require __DIR__ . '/dashboard-actions/save-settings.php';
  44. if ($aiSaveError !== null) {
  45. $flashMessage = $aiSaveError;
  46. $flashType = 'error';
  47. } else {
  48. $flashMessage = 'Settings saved.';
  49. }
  50. }
  51. ​
  52. if ($action === 'save_language') {
  53. require __DIR__ . '/dashboard-actions/save-language.php';
  54. $flashMessage = 'Language strings saved.';
  55. }
  56. ​
  57. if ($action === 'save_footer') {
  58. require __DIR__ . '/dashboard-actions/save-footer.php';
  59. $flashMessage = 'Footer saved.';
  60. }
  61. ​
  62. if ($action === 'add_nav_item') {
  63. require __DIR__ . '/dashboard-actions/save-nav.php';
  64. $flashMessage = 'Menu item added.';
  65. }
  66. ​
  67. if ($action === 'move_nav_item') {
  68. require __DIR__ . '/dashboard-actions/save-nav.php';
  69. }
  70. ​
  71. if ($action === 'delete_nav_item') {
  72. require __DIR__ . '/dashboard-actions/save-nav.php';
  73. $flashMessage = 'Menu item removed.';
  74. }
  75. ​
  76. if ($action === 'update_nav_item_icon') {
  77. require __DIR__ . '/dashboard-actions/save-nav.php';
  78. $flashMessage = 'Menu icon updated.';
  79. }
  80. ​
  81. if ($action === 'send_test_email') {
  82. require __DIR__ . '/dashboard-actions/send-test-email.php';
  83. }
  84. }
  85. }
  86. ​
  87. $settingsRows = $db->query('SELECT setting_key, setting_value FROM site_settings')->fetchAll();
  88. $settings = [];
  89. foreach ($settingsRows as $row) {
  90. $settings[$row['setting_key']] = $row['setting_value'];
  91. }
  92. ​
  93. $defaults = [
  94. 'site_name' => Config::get('APP_NAME', 'StocketBase'),
  95. 'site_description' => '',
  96. 'seo_title' => '',
  97. 'seo_description' => '',
  98. 'seo_keywords' => '',
  99. 'seo_author' => '',
  100. 'seo_reply_to' => '',
  101. 'seo_application_name' => '',
  102. 'seo_robots_index' => '1',
  103. 'seo_robots_follow' => '1',
  104. 'seo_og_product_image_enabled' => '0',
  105. 'clean_product_urls' => '0',
  106. 'llms_txt_enabled' => '0',
  107. 'site_logo_url' => '',
  108. 'site_show_name_with_logo' => '0',
  109. 'site_short_name' => '',
  110. 'site_short_name_enabled' => '0',
  111. 'registration_enabled' => '1',
  112. 'login_enabled' => '1',
  113. 'require_account_to_purchase' => '0',
  114. 'download_link_expiry_days' => '30',
  115. 'download_link_max_downloads' => '10',
  116. 'contact_enabled' => '0',
  117. 'contact_recipient_email' => '',
  118. 'contact_provider' => 'php_mail',
  119. 'contact_subject_mode' => 'free',
  120. 'contact_subjects' => '',
  121. 'contact_from_email' => '',
  122. 'contact_resend_api_key' => '',
  123. 'contact_postmark_token' => '',
  124. 'custom_css' => '',
  125. 'custom_js' => '',
  126. 'custom_head_html' => '',
  127. 'custom_head_html_index' => '0',
  128. 'custom_head_html_product' => '0',
  129. 'custom_head_html_category' => '0',
  130. 'custom_head_html_pages' => '0',
  131. 'custom_body_html' => '',
  132. 'custom_body_html_index' => '0',
  133. 'custom_body_html_product' => '0',
  134. 'custom_body_html_category' => '0',
  135. 'custom_body_html_pages' => '0',
  136. 'menu_position' => 'top',
  137. 'featured_banner_enabled' => '0',
  138. 'featured_banner_mode' => 'static',
  139. 'featured_banner_hide_duplicates' => '0',
  140. 'featured_banner_product_1' => '0',
  141. 'featured_banner_product_2' => '0',
  142. 'featured_banner_product_3' => '0',
  143. 'featured_banner_product_4' => '0',
  144. 'featured_banner_product_5' => '0',
  145. 'featured_banner_auto_window_value' => '24',
  146. 'featured_banner_auto_window_unit' => 'hours',
  147. 'featured_banner_auto_category_mode' => 'mixed',
  148. 'related_products_enabled' => '1',
  149. 'product_show_buyers' => '0',
  150. 'product_show_downloads' => '0',
  151. 'back_to_top_enabled' => '0',
  152. 'back_to_top_progress_enabled' => '0',
  153. 'breadcrumbs_enabled' => '0',
  154. 'tags_enabled' => '0',
  155. 'activity_log_enabled' => '0',
  156. 'seo_preview_enabled' => '0',
  157. 'indexnow_enabled' => '0',
  158. 'indexnow_key' => '',
  159. 'sitemap_rss_enabled' => '1',
  160. 'reviews_enabled' => '1',
  161. 'store_category' => 'general',
  162. 'pricing_model' => 'per_product',
  163. 'store_currency' => 'USD',
  164. 'payment_provider' => 'stripe',
  165. 'payment_webhook_secret' => '',
  166. 'payment_polar_webhook_secret' => '',
  167. 'payment_stripe_secret_key' => '',
  168. 'payment_stripe_publishable_key' => '',
  169. 'payment_polar_access_token' => '',
  170. 'payment_polar_organization_id' => '',
  171. 'subscription_interval' => 'month',
  172. 'mail_transport' => 'smtp',
  173. 'mail_smtp_host' => '',
  174. 'mail_smtp_port' => '587',
  175. 'mail_smtp_username' => '',
  176. 'mail_smtp_password' => '',
  177. 'mail_smtp_encryption' => 'tls',
  178. 'mail_resend_api_key' => '',
  179. 'mail_postmark_server_token' => '',
  180. 'mail_from_email' => '',
  181. 'mail_from_name' => '',
  182. 'mail_reply_to' => '',
  183. 'heading_font' => 'Plus Jakarta Sans',
  184. 'body_font' => 'PT Serif',
  185. 'interface_font' => 'Plus Jakarta Sans',
  186. 'footer_columns' => '[]',
  187. 'footer_social_links' => '[]',
  188. 'product_loading_mode' => 'scroll',
  189. 'pagination_style' => 'numbered',
  190. 'products_per_page_home' => '12',
  191. 'products_per_page_category' => '12',
  192. 'search_enabled' => '0',
  193. 'search_match_title' => '1',
  194. 'search_match_excerpt' => '1',
  195. 'search_match_content' => '0',
  196. 'search_style' => 'icon',
  197. 'search_show_in_nav' => '0',
  198. 'search_show_in_footer' => '0',
  199. 'share_enabled' => '0',
  200. 'share_facebook' => '1',
  201. 'share_twitter' => '1',
  202. 'share_linkedin' => '1',
  203. 'share_whatsapp' => '1',
  204. 'share_telegram' => '0',
  205. 'share_email' => '1',
  206. 'share_copy_link' => '1',
  207. 'pwa_enabled' => '0',
  208. 'pwa_name' => '',
  209. 'pwa_short_name' => '',
  210. 'pwa_description' => '',
  211. 'pwa_icon_url' => '',
  212. 'pwa_theme_color' => '#ffffff',
  213. 'pwa_background_color' => '#ffffff',
  214. 'app_banner_enabled' => '0',
  215. 'app_android_url' => '',
  216. 'app_ios_url' => '',
  217. ];
  218. $missingSettings = array_diff_key($defaults, $settings);
  219. if (!empty($missingSettings)) {
  220. $insertMissing = $db->prepare(
  221. 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = setting_value'
  222. );
  223. foreach ($missingSettings as $missingKey => $missingValue) {
  224. $insertMissing->execute(['key' => $missingKey, 'value' => $missingValue]);
  225. }
  226. }
  227. ​
  228. $settings = array_merge($defaults, $settings);
  229. ​
  230. $featuredProductOptions = $db->query(
  231. "SELECT id, title, published_at FROM products WHERE status = 'published' ORDER BY published_at DESC, id DESC"
  232. )->fetchAll();
  233. ​
  234. Language::ensureKeys(Language::ADDED_DEFAULTS);
  235. $languageStrings = Language::base();
  236. $availableFonts = FontLibrary::available();
  237. $activeTab = $_GET['tab'] ?? 'general';
  238. $allowedTabs = ['general', 'seo', 'layout', 'navigation', 'search', 'reviews', 'email', 'footer', 'apps', 'language', 'custom-code', 'ai'];
  239. if (!in_array($activeTab, $allowedTabs, true)) {
  240. $activeTab = 'general';
  241. }
  242. ​
  243. $navItemsRaw = [];
  244. $navAvailableCategories = [];
  245. $navAvailablePages = [];
  246. if ($activeTab === 'navigation') {
  247. try {
  248. $navItemsRaw = $db->query('SELECT * FROM nav_items ORDER BY sort_order ASC, id ASC')->fetchAll();
  249. } catch (PDOException $e) {
  250. $navItemsRaw = [];
  251. }
  252. ​
  253. $allCategoryNames = [];
  254. foreach ($db->query('SELECT id, name FROM categories')->fetchAll() as $category) {
  255. $allCategoryNames[(int) $category['id']] = $category['name'];
  256. }
  257. $allPageTitles = [];
  258. foreach ($db->query('SELECT id, title FROM pages')->fetchAll() as $page) {
  259. $allPageTitles[(int) $page['id']] = $page['title'];
  260. }
  261. ​
  262. $usedCategoryIds = [];
  263. $usedPageIds = [];
  264. foreach ($navItemsRaw as $navRow) {
  265. if ($navRow['item_type'] === 'category') {
  266. $usedCategoryIds[] = (int) $navRow['ref_id'];
  267. }
  268. if ($navRow['item_type'] === 'page') {
  269. $usedPageIds[] = (int) $navRow['ref_id'];
  270. }
  271. }
  272. ​
  273. $allCategories = $db->query('SELECT id, name FROM categories ORDER BY name ASC')->fetchAll();
  274. foreach ($allCategories as $category) {
  275. if (!in_array((int) $category['id'], $usedCategoryIds, true)) {
  276. $navAvailableCategories[] = $category;
  277. }
  278. }
  279. ​
  280. $allPages = $db->query('SELECT id, title FROM pages ORDER BY title ASC')->fetchAll();
  281. foreach ($allPages as $page) {
  282. if (!in_array((int) $page['id'], $usedPageIds, true)) {
  283. $navAvailablePages[] = $page;
  284. }
  285. }
  286. }
  287. ​
  288. $dashActivePage = 'settings';
  289. $dashPageTitle = 'Settings';
  290. ​
  291. require __DIR__ . '/includes/dash-header.php';
  292. ​
  293. ?>
  294. ​
  295. <?php if ($flashMessage !== null): ?>
  296. <div class="dash-flash dash-flash-<?= htmlspecialchars($flashType) ?>"><?= Icons::icon($flashType === 'error' ? 'x' : 'check', 'icon icon-sm') ?><?= htmlspecialchars($flashMessage) ?></div>
  297. <?php endif; ?>
  298. ​
  299. <h1 class="dash-title"><?= Icons::icon("settings", "icon icon-lg") ?>Settings</h1>
  300. ​
  301. <div class="settings-tabs">
  302. <a href="settings.php?tab=general" class="<?= $activeTab === 'general' ? 'active' : '' ?>"><?= Icons::icon("sliders", "icon icon-sm") ?>General</a>
  303. <a href="settings.php?tab=seo" class="<?= $activeTab === 'seo' ? 'active' : '' ?>"><?= Icons::icon("robot", "icon icon-sm") ?>SEO</a>
  304. <a href="settings.php?tab=layout" class="<?= $activeTab === 'layout' ? 'active' : '' ?>"><?= Icons::icon("layout", "icon icon-sm") ?>Layout</a>
  305. <a href="settings.php?tab=navigation" class="<?= $activeTab === 'navigation' ? 'active' : '' ?>"><?= Icons::icon("compass", "icon icon-sm") ?>Navigation</a>
  306. <a href="settings.php?tab=search" class="<?= $activeTab === 'search' ? 'active' : '' ?>"><?= Icons::icon("search", "icon icon-sm") ?>Search</a>
  307. <a href="settings.php?tab=reviews" class="<?= $activeTab === 'reviews' ? 'active' : '' ?>"><?= Icons::icon("star", "icon icon-sm") ?>Reviews</a>
  308. <a href="settings.php?tab=email" class="<?= $activeTab === 'email' ? 'active' : '' ?>"><?= Icons::icon("mail", "icon icon-sm") ?>Email</a>
  309. <a href="settings.php?tab=footer" class="<?= $activeTab === 'footer' ? 'active' : '' ?>"><?= Icons::icon("footer", "icon icon-sm") ?>Footer</a>
  310. <a href="settings.php?tab=apps" class="<?= $activeTab === 'apps' ? 'active' : '' ?>"><?= Icons::icon("smartphone", "icon icon-sm") ?>Apps</a>
  311. <a href="settings.php?tab=language" class="<?= $activeTab === 'language' ? 'active' : '' ?>"><?= Icons::icon("translate", "icon icon-sm") ?>Language</a>
  312. <a href="settings.php?tab=custom-code" class="<?= $activeTab === 'custom-code' ? 'active' : '' ?>"><?= Icons::icon("code", "icon icon-sm") ?>Custom code</a>
  313. <a href="settings.php?tab=ai" class="<?= $activeTab === 'ai' ? 'active' : '' ?>"><?= Icons::icon("sparkles", "icon icon-sm") ?>AI</a>
  314. </div>
  315. ​
  316. <?php if ($activeTab === 'general'): ?>
  317. <div class="sidebar-box updater-card">
  318. <div class="updater-card-info">
  319. <?= Icons::icon('refresh', 'icon icon-lg') ?>
  320. <div>
  321. <div class="updater-card-title">Updater</div>
  322. <div class="updater-card-version"><?= htmlspecialchars(AppVersion::current($db)) ?></div>
  323. </div>
  324. </div>
  325. <a href="updater.php" class="dash-btn dash-btn-primary"><?= Icons::icon('download', 'icon icon-sm') ?>Check Update?</a>
  326. </div>
  327. ​
  328. <form method="post" class="settings-section">
  329. <?= Csrf::field() ?>
  330. <input type="hidden" name="action" value="save_settings">
  331. <input type="hidden" name="settings_tab" value="general">
  332. ​
  333. <label>Site name</label>
  334. <input type="text" name="site_name" value="<?= htmlspecialchars($settings['site_name']) ?>">
  335. ​
  336. <label>Site description</label>
  337. <textarea name="site_description" rows="2"><?= htmlspecialchars($settings['site_description']) ?></textarea>
  338. ​
  339. <label>Logo URL (optional — without a logo the site name is shown as text)</label>
  340. <input type="text" name="site_logo_url" data-image-upload value="<?= htmlspecialchars($settings['site_logo_url']) ?>" placeholder="media/img/logo.png">
  341. ​
  342. <label><input type="checkbox" name="site_show_name_with_logo" <?= $settings['site_show_name_with_logo'] === '1' ? 'checked' : '' ?>> Show the site name next to the logo</label>
  343. ​
  344. <label>Short site name</label>
  345. <input type="text" name="site_short_name" maxlength="30" value="<?= htmlspecialchars($settings['site_short_name']) ?>" placeholder="<?= htmlspecialchars($settings['site_name']) ?>">
  346. ​
  347. <label><input type="checkbox" name="site_short_name_enabled" <?= $settings['site_short_name_enabled'] === '1' ? 'checked' : '' ?>> Use the short name when the full site name doesn't fit on the screen</label>
  348. ​
  349. <label><input type="checkbox" name="registration_enabled" <?= $settings['registration_enabled'] === '1' ? 'checked' : '' ?>> Allow visitors to create customer accounts (uncheck to close registration; existing customers can still log in)</label>
  350. ​
  351. <label><input type="checkbox" name="activity_log_enabled" <?= $settings['activity_log_enabled'] === '1' ? 'checked' : '' ?>> Enable activity log (records who changed products, categories, pages and settings; adds an Activity log page to the dashboard menu)</label>
  352. ​
  353. <label><input type="checkbox" name="login_enabled" <?= $settings['login_enabled'] === '1' ? 'checked' : '' ?>> Allow customer login (uncheck to fully disable the login form; useful for maintenance or closing the site to customers)</label>
  354. ​
  355. <label><input type="checkbox" name="require_account_to_purchase" <?= $settings['require_account_to_purchase'] === '1' ? 'checked' : '' ?>> Require a customer account to purchase (blocks guest checkout; visitors are sent to log in or create an account first). Also required to download free files.</label>
  356. ​
  357. <h2 class="dash-subtitle"><?= Icons::icon('download', 'icon icon-sm') ?>Digital downloads</h2>
  358. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 12px;">Customers with an account can always download what they bought from their account page. These limits apply to the personal download links sent by email after a purchase.</p>
  359. <div class="settings-grid">
  360. <div>
  361. <label>Download link valid for (days, 0 = never expires)</label>
  362. <input type="number" name="download_link_expiry_days" min="0" max="3650" value="<?= (int) $settings['download_link_expiry_days'] ?>">
  363. </div>
  364. <div>
  365. <label>Downloads allowed per link (0 = unlimited)</label>
  366. <input type="number" name="download_link_max_downloads" min="0" max="1000" value="<?= (int) $settings['download_link_max_downloads'] ?>">
  367. </div>
  368. </div>
  369. ​
  370. <h2 class="dash-subtitle"><?= Icons::icon('mail', 'icon icon-sm') ?>Contact System</h2>
  371. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 12px;">Lets visitors send you a message from a page. When it is on, add the "Contact form" block to a page in Pages (one per page). Every message must pass the security code check, otherwise it is rejected.</p>
  372. <label><input type="checkbox" name="contact_enabled" <?= $settings['contact_enabled'] === '1' ? 'checked' : '' ?>> Enable the contact form</label>
  373. ​
  374. <label>Send messages to (email address)</label>
  375. <input type="email" name="contact_recipient_email" value="<?= htmlspecialchars($settings['contact_recipient_email']) ?>" placeholder="you@example.com">
  376. ​
  377. <label>Subject</label>
  378. <select name="contact_subject_mode" id="contact_subject_mode_select">
  379. <option value="free" <?= $settings['contact_subject_mode'] === 'free' ? 'selected' : '' ?>>Visitors type their own subject</option>
  380. <option value="list_other" <?= $settings['contact_subject_mode'] === 'list_other' ? 'selected' : '' ?>>Choose from the list below, or "Other…" with their own subject</option>
  381. <option value="list_only" <?= $settings['contact_subject_mode'] === 'list_only' ? 'selected' : '' ?>>Only subjects from the list below (no custom subject)</option>
  382. </select>
  383. <?php
  384. require_once __DIR__ . '/includes/contact-form.php';
  385. $contactSubjectEntries = ContactForm::subjectEntries((string) $settings['contact_subjects']);
  386. ?>
  387. <div id="contact-subjects-field">
  388. <label>Ready-made subjects (up to <?= ContactForm::MAX_SUBJECTS ?>)</label>
  389. <p style="font-size:12px;color:var(--muted);margin:0 0 8px;">Give a topic its own email to route those messages there. Topics without an email go to the address above.</p>
  390. <div class="contact-topic-list" id="contact-topic-list">
  391. <?php foreach ($contactSubjectEntries as $topic): ?>
  392. <div class="contact-topic-row">
  393. <input type="text" name="contact_subject_name[]" value="<?= htmlspecialchars($topic['name']) ?>" maxlength="100" placeholder="Topic name, e.g. Technical support">
  394. <input type="email" name="contact_subject_email[]" value="<?= htmlspecialchars($topic['email']) ?>" maxlength="190" placeholder="Email (optional)">
  395. <button type="button" class="dash-btn-small dash-btn-danger js-remove-topic" aria-label="Remove topic"><?= Icons::icon('trash', 'icon icon-sm') ?></button>
  396. </div>
  397. <?php endforeach; ?>
  398. </div>
  399. <button type="button" class="dash-btn-small" id="js-add-topic"><?= Icons::icon('plus', 'icon icon-sm') ?>Add topic</button>
  400. <p style="font-size:12px;color:var(--muted);margin:8px 0 0;">If the list is empty, visitors type their own subject regardless of the option above.</p>
  401. <template id="contact-topic-template">
  402. <div class="contact-topic-row">
  403. <input type="text" name="contact_subject_name[]" maxlength="100" placeholder="Topic name, e.g. Technical support">
  404. <input type="email" name="contact_subject_email[]" maxlength="190" placeholder="Email (optional)">
  405. <button type="button" class="dash-btn-small dash-btn-danger js-remove-topic" aria-label="Remove topic"><?= Icons::icon('trash', 'icon icon-sm') ?></button>
  406. </div>
  407. </template>
  408. <script>
  409. (function() {
  410. var list = document.getElementById('contact-topic-list');
  411. var template = document.getElementById('contact-topic-template');
  412. var addButton = document.getElementById('js-add-topic');
  413. var max = <?= ContactForm::MAX_SUBJECTS ?>;
  414. function refresh() {
  415. addButton.disabled = list.querySelectorAll('.contact-topic-row').length >= max;
  416. }
  417. addButton.addEventListener('click', function() {
  418. if (list.querySelectorAll('.contact-topic-row').length >= max) {
  419. return;
  420. }
  421. list.appendChild(template.content.cloneNode(true));
  422. var inputs = list.querySelectorAll('.contact-topic-row input[type="text"]');
  423. inputs[inputs.length - 1].focus();
  424. refresh();
  425. });
  426. list.addEventListener('click', function(event) {
  427. var button = event.target.closest('.js-remove-topic');
  428. if (button) {
  429. button.closest('.contact-topic-row').remove();
  430. refresh();
  431. }
  432. });
  433. refresh();
  434. })();
  435. </script>
  436. </div>
  437. ​
  438. <label>Sending provider</label>
  439. <select name="contact_provider" id="contact_provider_select">
  440. <option value="php_mail" <?= $settings['contact_provider'] === 'php_mail' ? 'selected' : '' ?>>PHP mail() — works on any shared hosting, no account needed</option>
  441. <option value="resend" <?= $settings['contact_provider'] === 'resend' ? 'selected' : '' ?>>Resend (professional, API key)</option>
  442. <option value="postmark" <?= $settings['contact_provider'] === 'postmark' ? 'selected' : '' ?>>Postmark (professional, server token)</option>
  443. <option value="store_mailer" <?= $settings['contact_provider'] === 'store_mailer' ? 'selected' : '' ?>>Store email settings (Settings → Email: SMTP / Resend / Postmark) — most reliable</option>
  444. </select>
  445. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">PHP mail() only delivers on most hosts (e.g. Hostinger) when the sender address below is a real mailbox created on your domain. If messages do not arrive, check spam, set a real sender address, or use SMTP via "Store email settings".</p>
  446. ​
  447. <label>Sender address (optional)</label>
  448. <input type="email" name="contact_from_email" value="<?= htmlspecialchars($settings['contact_from_email']) ?>" placeholder="no-reply@yourdomain.com">
  449. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">Use an address on your own domain. With Resend or Postmark it must be a verified sender. The visitor's address is set as Reply-To, so you can answer directly.</p>
  450. ​
  451. <div id="contact-resend-fields">
  452. <label>Resend API key (leave empty to use the key from the Email tab)</label>
  453. <input type="password" name="contact_resend_api_key" value="<?= htmlspecialchars($settings['contact_resend_api_key']) ?>" autocomplete="new-password" placeholder="re_...">
  454. </div>
  455. <div id="contact-postmark-fields">
  456. <label>Postmark server token (leave empty to use the token from the Email tab)</label>
  457. <input type="password" name="contact_postmark_token" value="<?= htmlspecialchars($settings['contact_postmark_token']) ?>" autocomplete="new-password">
  458. </div>
  459. <script>
  460. (function() {
  461. var select = document.getElementById('contact_provider_select');
  462. var resend = document.getElementById('contact-resend-fields');
  463. var postmark = document.getElementById('contact-postmark-fields');
  464. var subjectMode = document.getElementById('contact_subject_mode_select');
  465. var subjectsField = document.getElementById('contact-subjects-field');
  466. function apply() {
  467. resend.style.display = select.value === 'resend' ? '' : 'none';
  468. postmark.style.display = select.value === 'postmark' ? '' : 'none';
  469. subjectsField.style.display = subjectMode.value === 'free' ? 'none' : '';
  470. }
  471. select.addEventListener('change', apply);
  472. subjectMode.addEventListener('change', apply);
  473. apply();
  474. })();
  475. </script>
  476. ​
  477. <h2 class="dash-subtitle"><?= Icons::icon('credit-card', 'icon icon-sm') ?>Store type</h2>
  478. ​
  479. <label>Store category</label>
  480. <select name="store_category" id="store_category_select">
  481. <option value="general" <?= $settings['store_category'] === 'general' ? 'selected' : '' ?>>General store (physical / digital products)</option>
  482. <option value="saas" <?= $settings['store_category'] === 'saas' ? 'selected' : '' ?>>SaaS software</option>
  483. </select>
  484. ​
  485. <label>Pricing model</label>
  486. <select name="pricing_model">
  487. <option value="per_product" <?= $settings['pricing_model'] === 'per_product' ? 'selected' : '' ?>>Per-product pricing</option>
  488. <option value="subscription" <?= $settings['pricing_model'] === 'subscription' ? 'selected' : '' ?>>Subscription</option>
  489. </select>
  490. ​
  491. <label>Default billing interval suggested for new subscription products</label>
  492. <select name="subscription_interval">
  493. <option value="month" <?= $settings['subscription_interval'] === 'month' ? 'selected' : '' ?>>Monthly</option>
  494. <option value="year" <?= $settings['subscription_interval'] === 'year' ? 'selected' : '' ?>>Yearly</option>
  495. <option value="lifetime" <?= $settings['subscription_interval'] === 'lifetime' ? 'selected' : '' ?>>One-time payment (lifetime access)</option>
  496. </select>
  497. ​
  498. <label>Store currency (default currency for new products — start typing a code or name, e.g. PLN, zloty, dollar)</label>
  499. <?php $storeCurrency = Currency::normalize((string) $settings['store_currency']); ?>
  500. <input type="text" name="store_currency" id="store-currency" list="store-currency-list" value="<?= htmlspecialchars($storeCurrency) ?>" maxlength="40" autocomplete="off" spellcheck="false" style="text-transform:uppercase;">
  501. <datalist id="store-currency-list">
  502. <?php foreach (Currency::all() as $currencyCode => $currencyName): ?>
  503. <option value="<?= htmlspecialchars($currencyCode) ?>"><?= htmlspecialchars(Currency::searchLabel($currencyCode)) ?></option>
  504. <?php endforeach; ?>
  505. </datalist>
  506. <script>
  507. (function() {
  508. var map = <?= json_encode(Currency::clientMap(), JSON_UNESCAPED_UNICODE) ?>;
  509. var currency = document.getElementById('store-currency');
  510. var lastValid = currency.value;
  511. ​
  512. currency.addEventListener('blur', function() {
  513. var code = currency.value.trim().toUpperCase();
  514. currency.value = map[code] ? code : lastValid;
  515. lastValid = currency.value;
  516. });
  517. })();
  518. </script>
  519. ​
  520. <label>Payment provider</label>
  521. <select name="payment_provider" id="payment_provider_select">
  522. <option value="stripe" <?= $settings['payment_provider'] === 'stripe' ? 'selected' : '' ?>>Stripe</option>
  523. <option value="polar" id="payment_provider_polar_option" <?= $settings['payment_provider'] === 'polar' ? 'selected' : '' ?> <?= $settings['store_category'] !== 'saas' ? 'disabled' : '' ?>>Polar.sh<?= $settings['store_category'] !== 'saas' ? ' — SaaS stores only' : '' ?></option>
  524. </select>
  525. ​
  526. <div id="stripe-payment-fields">
  527. <label>Stripe secret key</label>
  528. <input type="password" name="payment_stripe_secret_key" value="<?= htmlspecialchars($settings['payment_stripe_secret_key']) ?>" autocomplete="new-password" placeholder="sk_live_...">
  529. ​
  530. <label>Stripe publishable key</label>
  531. <input type="text" name="payment_stripe_publishable_key" value="<?= htmlspecialchars($settings['payment_stripe_publishable_key']) ?>" placeholder="pk_live_...">
  532. </div>
  533. ​
  534. <div id="polar-payment-fields">
  535. <label>Polar.sh access token</label>
  536. <input type="password" name="payment_polar_access_token" value="<?= htmlspecialchars($settings['payment_polar_access_token']) ?>" autocomplete="new-password">
  537. ​
  538. <label>Polar.sh organization ID</label>
  539. <input type="text" name="payment_polar_organization_id" value="<?= htmlspecialchars($settings['payment_polar_organization_id']) ?>">
  540. ​
  541. <label>Polar.sh webhook secret</label>
  542. <input type="password" name="payment_polar_webhook_secret" value="<?= htmlspecialchars($settings['payment_polar_webhook_secret']) ?>" autocomplete="new-password">
  543. </div>
  544. ​
  545. <script>
  546. (function() {
  547. var storeCategorySelect = document.getElementById('store_category_select');
  548. var paymentProviderSelect = document.getElementById('payment_provider_select');
  549. var polarOption = document.getElementById('payment_provider_polar_option');
  550. var stripeFields = document.getElementById('stripe-payment-fields');
  551. var polarFields = document.getElementById('polar-payment-fields');
  552. ​
  553. function applyPaymentProvider() {
  554. stripeFields.style.display = '';
  555. polarFields.style.display = storeCategorySelect.value === 'saas' ? '' : 'none';
  556. }
  557. ​
  558. function applyStoreCategory() {
  559. var isSaas = storeCategorySelect.value === 'saas';
  560. polarOption.disabled = !isSaas;
  561. polarOption.textContent = isSaas ? 'Polar.sh' : 'Polar.sh — SaaS stores only';
  562. if (!isSaas && paymentProviderSelect.value === 'polar') {
  563. paymentProviderSelect.value = 'stripe';
  564. }
  565. applyPaymentProvider();
  566. }
  567. ​
  568. storeCategorySelect.addEventListener('change', applyStoreCategory);
  569. paymentProviderSelect.addEventListener('change', applyPaymentProvider);
  570. applyStoreCategory();
  571. })();
  572. </script>
  573. ​
  574. <label>Stripe webhook signing secret</label>
  575. <input type="password" name="payment_webhook_secret" value="<?= htmlspecialchars($settings['payment_webhook_secret']) ?>" autocomplete="new-password" placeholder="whsec_...">
  576. <p style="font-size:12px;color:var(--muted);margin-top:6px;">Webhook endpoint: <code><?= htmlspecialchars(rtrim(Config::get('APP_URL', ''), '/') . Config::get('APP_BASE_PATH', '')) ?>/webhook.php</code></p>
  577. ​
  578. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  579. </form>
  580. <?php endif; ?>
  581. ​
  582. <?php if ($activeTab === 'seo'): ?>
  583. <form method="post" class="settings-section">
  584. <?= Csrf::field() ?>
  585. <input type="hidden" name="action" value="save_settings">
  586. <input type="hidden" name="settings_tab" value="seo">
  587. ​
  588. <label>SEO title</label>
  589. <input type="text" name="seo_title" value="<?= htmlspecialchars($settings['seo_title']) ?>">
  590. ​
  591. <label>SEO description</label>
  592. <textarea name="seo_description" rows="2"><?= htmlspecialchars($settings['seo_description']) ?></textarea>
  593. ​
  594. <label>Keywords (comma separated)</label>
  595. <input type="text" name="seo_keywords" value="<?= htmlspecialchars($settings['seo_keywords']) ?>" placeholder="news, technology, business">
  596. ​
  597. <label>Author</label>
  598. <input type="text" name="seo_author" value="<?= htmlspecialchars($settings['seo_author']) ?>">
  599. ​
  600. <label>Reply-to email</label>
  601. <input type="email" name="seo_reply_to" value="<?= htmlspecialchars($settings['seo_reply_to']) ?>">
  602. ​
  603. <label>Application name</label>
  604. <input type="text" name="seo_application_name" value="<?= htmlspecialchars($settings['seo_application_name']) ?>">
  605. ​
  606. <h2 class="dash-subtitle"><?= Icons::icon('robot', 'icon icon-sm') ?>Robots</h2>
  607. <label><input type="checkbox" name="seo_robots_index" <?= $settings['seo_robots_index'] === '1' ? 'checked' : '' ?>> Allow search engines to index this site</label>
  608. <label><input type="checkbox" name="seo_robots_follow" <?= $settings['seo_robots_follow'] === '1' ? 'checked' : '' ?>> Allow search engines to follow links on this site</label>
  609. ​
  610. <h2 class="dash-subtitle"><?= Icons::icon('share', 'icon icon-sm') ?>Social sharing (Open Graph)</h2>
  611. <label><input type="checkbox" name="seo_og_product_image_enabled" <?= $settings['seo_og_product_image_enabled'] === '1' ? 'checked' : '' ?>> Use each product's cover image as its social preview image (falls back to the site logo when off, or when a product has no cover image)</label>
  612. <label><input type="checkbox" name="clean_product_urls" <?= $settings['clean_product_urls'] === '1' ? 'checked' : '' ?>> Use clean product URLs without product.php?slug=</label>
  613. <label><input type="checkbox" name="seo_preview_enabled" <?= $settings['seo_preview_enabled'] === '1' ? 'checked' : '' ?>> Show MevaSearch and social media preview in the product editor</label>
  614. <label><input type="checkbox" name="indexnow_enabled" <?= $settings['indexnow_enabled'] === '1' ? 'checked' : '' ?>> Enable IndexNow (notify search engines when a product is published, updated or deleted)</label>
  615. <?php if ($settings['indexnow_enabled'] === '1' && $settings['indexnow_key'] !== ''): ?>
  616. <p style="font-size:12px;color:var(--muted);margin-top:6px;">Key: <code><?= htmlspecialchars($settings['indexnow_key']) ?></code> &middot; verification URL: <code><?= htmlspecialchars(rtrim(Config::get('APP_URL', ''), '/') . rtrim(Config::get('APP_BASE_PATH', ''), '/') . '/' . $settings['indexnow_key'] . '.txt') ?></code></p>
  617. <?php endif; ?>
  618. <label><input type="checkbox" name="llms_txt_enabled" <?= $settings['llms_txt_enabled'] === '1' ? 'checked' : '' ?>> Enable /llms.txt for AI-readable site information</label>
  619. ​
  620. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  621. </form>
  622. <?php endif; ?>
  623. ​
  624. <?php if ($activeTab === 'layout'): ?>
  625. <form method="post" class="settings-section">
  626. <?= Csrf::field() ?>
  627. <input type="hidden" name="action" value="save_settings">
  628. <input type="hidden" name="settings_tab" value="layout">
  629. ​
  630. <label>Site menu position</label>
  631. <select name="menu_position">
  632. <option value="top" <?= $settings['menu_position'] === 'top' ? 'selected' : '' ?>>Top</option>
  633. <option value="bottom" <?= $settings['menu_position'] === 'bottom' ? 'selected' : '' ?>>Bottom</option>
  634. <option value="side" <?= $settings['menu_position'] === 'side' ? 'selected' : '' ?>>Side</option>
  635. </select>
  636. ​
  637. <label><input type="checkbox" name="featured_banner_enabled" <?= $settings['featured_banner_enabled'] === '1' ? 'checked' : '' ?>> Show Featured banner on the homepage</label>
  638. ​
  639. <h2 class="dash-subtitle"><?= Icons::icon('sparkles', 'icon icon-sm') ?>Featured banner</h2>
  640. <label>Product selection</label>
  641. <select name="featured_banner_mode" id="featured_banner_mode_select">
  642. <option value="static" <?= $settings['featured_banner_mode'] === 'static' ? 'selected' : '' ?>>Static selection</option>
  643. <option value="automatic" <?= $settings['featured_banner_mode'] === 'automatic' ? 'selected' : '' ?>>Automatic selection</option>
  644. </select>
  645. ​
  646. <label><input type="checkbox" name="featured_banner_hide_duplicates" <?= $settings['featured_banner_hide_duplicates'] === '1' ? 'checked' : '' ?>> Don't show again: hide Featured products in the latest products list</label>
  647. ​
  648. <div id="featured-banner-static-fields">
  649. <div class="settings-grid">
  650. <?php for ($featuredSlot = 1; $featuredSlot <= 5; $featuredSlot++): ?>
  651. <div>
  652. <label>Featured product <?= $featuredSlot ?></label>
  653. <select name="featured_banner_product_<?= $featuredSlot ?>">
  654. <option value="0">— None —</option>
  655. <?php foreach ($featuredProductOptions as $featuredOption): ?>
  656. <option value="<?= (int) $featuredOption['id'] ?>" <?= (string) $settings['featured_banner_product_' . $featuredSlot] === (string) $featuredOption['id'] ? 'selected' : '' ?>><?= htmlspecialchars($featuredOption['title']) ?></option>
  657. <?php endforeach; ?>
  658. </select>
  659. </div>
  660. <?php endfor; ?>
  661. </div>
  662. </div>
  663. ​
  664. <div id="featured-banner-auto-fields">
  665. <label>Automatic period</label>
  666. <div class="settings-grid">
  667. <input type="number" name="featured_banner_auto_window_value" min="1" max="720" value="<?= (int) $settings['featured_banner_auto_window_value'] ?>">
  668. <select name="featured_banner_auto_window_unit">
  669. <option value="minutes" <?= $settings['featured_banner_auto_window_unit'] === 'minutes' ? 'selected' : '' ?>>Minutes</option>
  670. <option value="hours" <?= $settings['featured_banner_auto_window_unit'] === 'hours' ? 'selected' : '' ?>>Hours</option>
  671. </select>
  672. </div>
  673. ​
  674. <label>Automatic category distribution</label>
  675. <select name="featured_banner_auto_category_mode">
  676. <option value="mixed" <?= $settings['featured_banner_auto_category_mode'] === 'mixed' ? 'selected' : '' ?>>Mix all categories</option>
  677. <option value="one_per_category" <?= $settings['featured_banner_auto_category_mode'] === 'one_per_category' ? 'selected' : '' ?>>One product per category</option>
  678. <option value="no_limit" <?= $settings['featured_banner_auto_category_mode'] === 'no_limit' ? 'selected' : '' ?>>No category limit</option>
  679. </select>
  680. </div>
  681. ​
  682. <script>
  683. (function() {
  684. var modeSelect = document.getElementById('featured_banner_mode_select');
  685. var staticFields = document.getElementById('featured-banner-static-fields');
  686. var autoFields = document.getElementById('featured-banner-auto-fields');
  687. ​
  688. function apply() {
  689. var isAutomatic = modeSelect.value === 'automatic';
  690. staticFields.style.display = isAutomatic ? 'none' : '';
  691. autoFields.style.display = isAutomatic ? '' : 'none';
  692. }
  693. ​
  694. modeSelect.addEventListener('change', apply);
  695. apply();
  696. })();
  697. </script>
  698. ​
  699. <label><input type="checkbox" name="back_to_top_enabled" <?= $settings['back_to_top_enabled'] === '1' ? 'checked' : '' ?>> Show a "Back to top" button after scrolling down</label>
  700. <label><input type="checkbox" name="back_to_top_progress_enabled" <?= $settings['back_to_top_progress_enabled'] === '1' ? 'checked' : '' ?>> Show how much of the page is left to scroll (in %) inside the "Back to top" button</label>
  701. <label><input type="checkbox" name="breadcrumbs_enabled" <?= $settings['breadcrumbs_enabled'] === '1' ? 'checked' : '' ?>> Show breadcrumbs above the product title (Home › Category › Product)</label>
  702. <label><input type="checkbox" name="tags_enabled" <?= $settings['tags_enabled'] === '1' ? 'checked' : '' ?>> Enable tags (tags field in the product editor, tag links under products and tag pages at tag.php)</label>
  703. <label><input type="checkbox" name="related_products_enabled" <?= $settings['related_products_enabled'] === '1' ? 'checked' : '' ?>> Show related / latest products below each product</label>
  704. <label><input type="checkbox" name="product_show_buyers" <?= $settings['product_show_buyers'] === '1' ? 'checked' : '' ?>> Show how many people bought a product on its page (hidden while it is 0)</label>
  705. <label><input type="checkbox" name="product_show_downloads" <?= $settings['product_show_downloads'] === '1' ? 'checked' : '' ?>> Show how many times a product's files were downloaded on its page (hidden while it is 0)</label>
  706. <label><input type="checkbox" name="sitemap_rss_enabled" <?= $settings['sitemap_rss_enabled'] === '1' ? 'checked' : '' ?>> Generate sitemap.xml and RSS feed</label>
  707. ​
  708. <h2 class="dash-subtitle"><?= Icons::icon('more', 'icon icon-sm') ?>Pagination</h2>
  709. <label>Product loading</label>
  710. <select name="product_loading_mode">
  711. <option value="scroll" <?= $settings['product_loading_mode'] === 'scroll' ? 'selected' : '' ?>>Scroll / paginated loading</option>
  712. <option value="static" <?= $settings['product_loading_mode'] === 'static' ? 'selected' : '' ?>>Static — show all products immediately</option>
  713. </select>
  714. <label>Style</label>
  715. <select name="pagination_style">
  716. <option value="numbered" <?= $settings['pagination_style'] === 'numbered' ? 'selected' : '' ?>>Numbered pages (1 2 3 … Next)</option>
  717. <option value="load_more" <?= $settings['pagination_style'] === 'load_more' ? 'selected' : '' ?>>Load more button</option>
  718. <option value="infinite_scroll" <?= $settings['pagination_style'] === 'infinite_scroll' ? 'selected' : '' ?>>Infinite scroll</option>
  719. </select>
  720. ​
  721. <label>Products per page — Home</label>
  722. <input type="number" name="products_per_page_home" min="1" max="100" value="<?= (int) $settings['products_per_page_home'] ?>">
  723. ​
  724. <label>Products per page — Category</label>
  725. <input type="number" name="products_per_page_category" min="1" max="100" value="<?= (int) $settings['products_per_page_category'] ?>">
  726. ​
  727. <label>Heading font (h1, h2, h3)</label>
  728. <select name="heading_font">
  729. <?php if (empty($availableFonts)): ?>
  730. <option value="<?= htmlspecialchars($settings['heading_font']) ?>"><?= htmlspecialchars($settings['heading_font']) ?> (no fonts detected in assets/fonts/)</option>
  731. <?php endif; ?>
  732. <?php foreach ($availableFonts as $font): ?>
  733. <option value="<?= htmlspecialchars($font['display_name']) ?>" <?= $settings['heading_font'] === $font['display_name'] ? 'selected' : '' ?>><?= htmlspecialchars($font['display_name']) ?></option>
  734. <?php endforeach; ?>
  735. </select>
  736. ​
  737. <label>Body text font</label>
  738. <select name="body_font">
  739. <?php if (empty($availableFonts)): ?>
  740. <option value="<?= htmlspecialchars($settings['body_font']) ?>"><?= htmlspecialchars($settings['body_font']) ?> (no fonts detected in assets/fonts/)</option>
  741. <?php endif; ?>
  742. <?php foreach ($availableFonts as $font): ?>
  743. <option value="<?= htmlspecialchars($font['display_name']) ?>" <?= $settings['body_font'] === $font['display_name'] ? 'selected' : '' ?>><?= htmlspecialchars($font['display_name']) ?></option>
  744. <?php endforeach; ?>
  745. </select>
  746. ​
  747. <label>Interface font (navigation, buttons, labels, badges, messages)</label>
  748. <select name="interface_font">
  749. <?php if (empty($availableFonts)): ?>
  750. <option value="<?= htmlspecialchars($settings['interface_font']) ?>"><?= htmlspecialchars($settings['interface_font']) ?> (no fonts detected in assets/fonts/)</option>
  751. <?php endif; ?>
  752. <?php foreach ($availableFonts as $font): ?>
  753. <option value="<?= htmlspecialchars($font['display_name']) ?>" <?= $settings['interface_font'] === $font['display_name'] ? 'selected' : '' ?>><?= htmlspecialchars($font['display_name']) ?></option>
  754. <?php endforeach; ?>
  755. </select>
  756. ​
  757. <h2 class="dash-subtitle"><?= Icons::icon('share', 'icon icon-sm') ?>Share on social media</h2>
  758. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Shows a Share button on products that opens a popup panel with links to share the product.</p>
  759. <label><input type="checkbox" name="share_enabled" <?= $settings['share_enabled'] === '1' ? 'checked' : '' ?>> Enable the Share button on products</label>
  760. ​
  761. <div class="settings-grid">
  762. <label class="share-settings-platform"><input type="checkbox" name="share_facebook" <?= $settings['share_facebook'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><?= SocialIcons::builtinSvg('facebook') ?></span> Facebook</label>
  763. <label class="share-settings-platform"><input type="checkbox" name="share_twitter" <?= $settings['share_twitter'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><?= SocialIcons::builtinSvg('twitter') ?></span> Twitter / X</label>
  764. <label class="share-settings-platform"><input type="checkbox" name="share_linkedin" <?= $settings['share_linkedin'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><?= SocialIcons::builtinSvg('linkedin') ?></span> LinkedIn</label>
  765. <label class="share-settings-platform"><input type="checkbox" name="share_whatsapp" <?= $settings['share_whatsapp'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><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></span> WhatsApp</label>
  766. <label class="share-settings-platform"><input type="checkbox" name="share_telegram" <?= $settings['share_telegram'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><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></span> Telegram</label>
  767. <label class="share-settings-platform"><input type="checkbox" name="share_email" <?= $settings['share_email'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><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></span> Email</label>
  768. <label class="share-settings-platform"><input type="checkbox" name="share_copy_link" <?= $settings['share_copy_link'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><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></span> Copy link</label>
  769. </div>
  770. ​
  771. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  772. </form>
  773. <?php endif; ?>
  774. ​
  775. <?php if ($activeTab === 'navigation'): ?>
  776. <?php
  777. function renderNavIconPickerFields(string $selectedKey = '', string $selectedSvg = ''): void
  778. {
  779. $previewKey = Icons::isNavIcon($selectedKey) ? $selectedKey : 'home';
  780. ?>
  781. <label>Icon (optional)</label>
  782. <div class="nav-icon-picker-row">
  783. <span class="nav-icon-picker-preview"><?= Icons::icon($previewKey, 'icon js-nav-icon-preview') ?></span>
  784. <select name="icon_key" class="js-nav-icon-select">
  785. <option value="" <?= $selectedKey === '' ? 'selected' : '' ?>>No icon</option>
  786. <?php foreach (Icons::navIconKeys() as $navIconKeyOption => $navIconLabel): ?>
  787. <option value="<?= htmlspecialchars($navIconKeyOption) ?>" <?= $selectedKey === $navIconKeyOption ? 'selected' : '' ?>><?= htmlspecialchars($navIconLabel) ?></option>
  788. <?php endforeach; ?>
  789. <option value="custom" <?= $selectedKey === 'custom' ? 'selected' : '' ?>>Custom SVG…</option>
  790. </select>
  791. </div>
  792. <div class="js-nav-icon-custom-wrap" style="<?= $selectedKey === 'custom' ? '' : 'display:none;' ?>">
  793. <textarea name="icon_svg" class="be-code js-nav-icon-svg" rows="2" placeholder='<svg viewBox="0 0 24 24">...</svg>'><?= htmlspecialchars($selectedSvg) ?></textarea>
  794. </div>
  795. <?php
  796. }
  797. ?>
  798. <?php
  799. $shopIconPresets = [
  800. 'cart' => 'Basket',
  801. 'cart-basket2' => 'Basket (straps)',
  802. 'cart-trolley' => 'Trolley',
  803. 'cart-bag' => 'Shopping bag',
  804. 'cart-tote' => 'Tote bag',
  805. 'cart-box' => 'Package',
  806. 'cart-wallet' => 'Wallet',
  807. 'cart-tag' => 'Price tag',
  808. 'cart-storefront' => 'Storefront',
  809. 'cart-percent' => 'Discount',
  810. ];
  811. $shopIconChoice = (string) ($settings['shop_icon_choice'] ?? 'cart');
  812. if (!isset($shopIconPresets[$shopIconChoice]) && $shopIconChoice !== 'custom') {
  813. $shopIconChoice = 'cart';
  814. }
  815. ?>
  816. <form method="post" class="settings-section">
  817. <?= Csrf::field() ?>
  818. <input type="hidden" name="action" value="save_settings">
  819. <input type="hidden" name="settings_tab" value="shop_icon">
  820. ​
  821. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('cart', 'icon icon-sm') ?>Shop icon</h2>
  822. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 12px;">Shown next to the cart in the site header. Pick a preset, or provide your own SVG.</p>
  823. <div class="shop-icon-picker">
  824. <?php foreach ($shopIconPresets as $presetKey => $presetLabel): ?>
  825. <label class="shop-icon-option" title="<?= htmlspecialchars($presetLabel) ?>">
  826. <input type="radio" name="shop_icon_choice" value="<?= htmlspecialchars($presetKey) ?>" <?= $shopIconChoice === $presetKey ? 'checked' : '' ?>>
  827. <?= Icons::icon($presetKey, 'icon') ?>
  828. </label>
  829. <?php endforeach; ?>
  830. <label class="shop-icon-option shop-icon-option-custom" title="Custom SVG">
  831. <input type="radio" name="shop_icon_choice" value="custom" <?= $shopIconChoice === 'custom' ? 'checked' : '' ?>>
  832. <?= Icons::icon('code', 'icon') ?>
  833. </label>
  834. </div>
  835. <div class="shop-icon-custom-wrap">
  836. <label>Custom SVG markup</label>
  837. <textarea name="shop_icon_custom_svg" class="be-code" rows="3" placeholder='<svg viewBox="0 0 24 24">...</svg>'><?= htmlspecialchars((string) ($settings['shop_icon_custom_svg'] ?? '')) ?></textarea>
  838. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">Pasted as-is in the page — make sure it only contains an &lt;svg&gt; element from a source you trust.</p>
  839. </div>
  840. ​
  841. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save shop icon</button>
  842. </form>
  843. ​
  844. <div class="settings-section">
  845. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 20px;">Controls what appears in the site's main navigation menu, and in what order. If this list is empty, the menu falls back to showing all categories and pages marked "show in menu".</p>
  846. ​
  847. <?php if (empty($navItemsRaw)): ?>
  848. <p style="color:var(--muted);font-size:13.5px;">No custom menu yet — the site is showing its automatic fallback menu (categories, then pages). Add an item below to take over control of the menu.</p>
  849. <?php else: ?>
  850. <table class="dash-table">
  851. <thead>
  852. <tr>
  853. <th></th>
  854. <th><?= Icons::icon('heading', 'icon icon-sm') ?>Label</th>
  855. <th><?= Icons::icon('tag', 'icon icon-sm') ?>Type</th>
  856. <th><?= Icons::icon('link', 'icon icon-sm') ?>Target</th>
  857. <th></th>
  858. </tr>
  859. </thead>
  860. <tbody>
  861. <?php foreach ($navItemsRaw as $navIndex => $navRow): ?>
  862. <?php
  863. if ($navRow['item_type'] === 'category') {
  864. $navDisplayLabel = $navRow['label'] !== null && $navRow['label'] !== '' ? $navRow['label'] : ($allCategoryNames[(int) $navRow['ref_id']] ?? '(deleted category)');
  865. $navTypeLabel = 'Category';
  866. } elseif ($navRow['item_type'] === 'page') {
  867. $navDisplayLabel = $navRow['label'] !== null && $navRow['label'] !== '' ? $navRow['label'] : ($allPageTitles[(int) $navRow['ref_id']] ?? '(deleted page)');
  868. $navTypeLabel = 'Page';
  869. } else {
  870. $navDisplayLabel = $navRow['label'] ?? '';
  871. $navTypeLabel = 'Custom link';
  872. }
  873. $navRowIconKey = (string) ($navRow['icon_key'] ?? '');
  874. $navRowIconSvg = (string) ($navRow['icon_svg'] ?? '');
  875. ?>
  876. <tr>
  877. <td>
  878. <?php if ($navRowIconKey === 'custom' && $navRowIconSvg !== ''): ?>
  879. <span class="nav-item-icon" aria-hidden="true"><?= $navRowIconSvg ?></span>
  880. <?php elseif ($navRowIconKey !== '' && Icons::isNavIcon($navRowIconKey)): ?>
  881. <?= Icons::icon($navRowIconKey, 'icon icon-sm nav-item-icon') ?>
  882. <?php else: ?>
  883. <span style="color:var(--muted);">—</span>
  884. <?php endif; ?>
  885. </td>
  886. <td><?= htmlspecialchars($navDisplayLabel) ?><?php if ($navRow['item_type'] === 'custom'): ?><div style="font-size:12px;color:var(--muted);"><?= htmlspecialchars($navRow['url']) ?></div><?php endif; ?></td>
  887. <td><?= htmlspecialchars($navTypeLabel) ?></td>
  888. <td><?= $navRow['target'] === '_blank' ? 'New tab' : 'Same tab' ?></td>
  889. <td>
  890. <div class="dash-table-actions">
  891. <?php if ($navIndex > 0): ?>
  892. <form method="post" style="display:inline;">
  893. <?= Csrf::field() ?>
  894. <input type="hidden" name="action" value="move_nav_item">
  895. <input type="hidden" name="nav_item_id" value="<?= (int) $navRow['id'] ?>">
  896. <input type="hidden" name="direction" value="up">
  897. <button type="submit" class="dash-btn-small" aria-label="Move up"><?= Icons::icon('arrow-up', 'icon icon-sm') ?></button>
  898. </form>
  899. <?php endif; ?>
  900. <?php if ($navIndex < count($navItemsRaw) - 1): ?>
  901. <form method="post" style="display:inline;">
  902. <?= Csrf::field() ?>
  903. <input type="hidden" name="action" value="move_nav_item">
  904. <input type="hidden" name="nav_item_id" value="<?= (int) $navRow['id'] ?>">
  905. <input type="hidden" name="direction" value="down">
  906. <button type="submit" class="dash-btn-small" aria-label="Move down"><?= Icons::icon('arrow-down', 'icon icon-sm') ?></button>
  907. </form>
  908. <?php endif; ?>
  909. <button type="button" class="dash-btn-small js-edit-nav-icon"
  910. data-id="<?= (int) $navRow['id'] ?>"
  911. data-icon-key="<?= htmlspecialchars($navRowIconKey, ENT_QUOTES) ?>"
  912. data-icon-svg="<?= htmlspecialchars($navRowIconSvg, ENT_QUOTES) ?>"><?= Icons::icon('edit', 'icon icon-sm') ?>Icon</button>
  913. <form method="post" style="display:inline;" onsubmit="return confirm('Remove this menu item?');">
  914. <?= Csrf::field() ?>
  915. <input type="hidden" name="action" value="delete_nav_item">
  916. <input type="hidden" name="nav_item_id" value="<?= (int) $navRow['id'] ?>">
  917. <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Remove</button>
  918. </form>
  919. </div>
  920. </td>
  921. </tr>
  922. <?php endforeach; ?>
  923. </tbody>
  924. </table>
  925. <?php endif; ?>
  926. </div>
  927. ​
  928. <div class="sidebar-box" style="max-width:520px;margin-top:20px;display:none;" id="nav-icon-form-wrapper">
  929. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('star', 'icon icon-sm') ?>Menu item icon</h2>
  930. <form method="post" id="nav-icon-form">
  931. <?= Csrf::field() ?>
  932. <input type="hidden" name="action" value="update_nav_item_icon">
  933. <input type="hidden" name="nav_item_id" id="nav_icon_item_id_input" value="">
  934. <?php renderNavIconPickerFields(); ?>
  935. <div class="publish-actions" style="margin-top:12px;">
  936. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save icon</button>
  937. <button type="button" class="dash-btn" id="nav-icon-cancel-btn"><?= Icons::icon('x', 'icon icon-sm') ?>Cancel</button>
  938. </div>
  939. </form>
  940. </div>
  941. ​
  942. <div class="sidebar-box" style="max-width:520px;margin-top:20px;">
  943. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('plus', 'icon icon-sm') ?>Add menu item</h2>
  944. ​
  945. <div class="settings-tabs" id="nav-add-type-tabs" style="margin-bottom:16px;">
  946. <a href="#" class="active" data-nav-type="category"><?= Icons::icon("categories", "icon icon-sm") ?>Category</a>
  947. <a href="#" data-nav-type="page"><?= Icons::icon("pages", "icon icon-sm") ?>Page</a>
  948. <a href="#" data-nav-type="custom"><?= Icons::icon("link", "icon icon-sm") ?>Custom link</a>
  949. </div>
  950. ​
  951. <form method="post" id="nav-add-form">
  952. <?= Csrf::field() ?>
  953. <input type="hidden" name="action" value="add_nav_item">
  954. <input type="hidden" name="item_type" id="nav_item_type_input" value="category">
  955. ​
  956. <div id="nav-add-category-fields">
  957. <label>Category</label>
  958. <select name="category_ref_id">
  959. <?php if (empty($navAvailableCategories)): ?>
  960. <option value="">— no categories available —</option>
  961. <?php endif; ?>
  962. <?php foreach ($navAvailableCategories as $navCategoryOption): ?>
  963. <option value="<?= (int) $navCategoryOption['id'] ?>"><?= htmlspecialchars($navCategoryOption['name']) ?></option>
  964. <?php endforeach; ?>
  965. </select>
  966. </div>
  967. ​
  968. <div id="nav-add-page-fields" style="display:none;">
  969. <label>Page</label>
  970. <select name="page_ref_id">
  971. <?php if (empty($navAvailablePages)): ?>
  972. <option value="">— no pages available —</option>
  973. <?php endif; ?>
  974. <?php foreach ($navAvailablePages as $navPageOption): ?>
  975. <option value="<?= (int) $navPageOption['id'] ?>"><?= htmlspecialchars($navPageOption['title']) ?></option>
  976. <?php endforeach; ?>
  977. </select>
  978. </div>
  979. ​
  980. <div id="nav-add-custom-fields" style="display:none;">
  981. <label>Label</label>
  982. <input type="text" name="custom_label">
  983. ​
  984. <label>URL</label>
  985. <input type="text" name="custom_url" placeholder="https://example.com or a relative page">
  986. ​
  987. <label><input type="checkbox" name="custom_new_tab"> Open in a new tab</label>
  988. </div>
  989. ​
  990. <label id="nav-label-override-wrap" style="display:none;">Label override (optional — leave blank to use the category/page name)</label>
  991. <input type="text" name="label_override" id="nav_label_override_input" style="display:none;">
  992. ​
  993. <?php renderNavIconPickerFields(); ?>
  994. ​
  995. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('plus', 'icon icon-sm') ?>Add to menu</button>
  996. </form>
  997. </div>
  998. ​
  999. <script>
  1000. (function() {
  1001. function wireIconPicker(form) {
  1002. var select = form.querySelector('.js-nav-icon-select');
  1003. var preview = form.querySelector('.js-nav-icon-preview use');
  1004. var customWrap = form.querySelector('.js-nav-icon-custom-wrap');
  1005. ​
  1006. function apply() {
  1007. var isCustom = select.value === 'custom';
  1008. customWrap.style.display = isCustom ? '' : 'none';
  1009. if (!isCustom && select.value !== '') {
  1010. preview.setAttribute('href', '#icon-' + select.value);
  1011. }
  1012. form.querySelector('.js-nav-icon-preview').style.visibility = (select.value === '' || isCustom) ? 'hidden' : 'visible';
  1013. }
  1014. ​
  1015. select.addEventListener('change', apply);
  1016. apply();
  1017. }
  1018. ​
  1019. document.querySelectorAll('#nav-icon-form, #nav-add-form').forEach(wireIconPicker);
  1020. ​
  1021. var iconWrapper = document.getElementById('nav-icon-form-wrapper');
  1022. var iconForm = document.getElementById('nav-icon-form');
  1023. ​
  1024. document.querySelectorAll('.js-edit-nav-icon').forEach(function(button) {
  1025. button.addEventListener('click', function() {
  1026. document.getElementById('nav_icon_item_id_input').value = this.dataset.id;
  1027. var select = iconForm.querySelector('.js-nav-icon-select');
  1028. var svgTextarea = iconForm.querySelector('.js-nav-icon-svg');
  1029. select.value = this.dataset.iconKey || '';
  1030. svgTextarea.value = this.dataset.iconSvg || '';
  1031. select.dispatchEvent(new Event('change'));
  1032. iconWrapper.style.display = '';
  1033. iconWrapper.scrollIntoView({
  1034. behavior: 'smooth',
  1035. block: 'nearest'
  1036. });
  1037. });
  1038. });
  1039. ​
  1040. document.getElementById('nav-icon-cancel-btn').addEventListener('click', function() {
  1041. iconWrapper.style.display = 'none';
  1042. });
  1043. })();
  1044. </script>
  1045. ​
  1046. <script>
  1047. (function() {
  1048. var typeTabs = document.querySelectorAll('#nav-add-type-tabs a');
  1049. var typeInput = document.getElementById('nav_item_type_input');
  1050. var categoryFields = document.getElementById('nav-add-category-fields');
  1051. var pageFields = document.getElementById('nav-add-page-fields');
  1052. var customFields = document.getElementById('nav-add-custom-fields');
  1053. var labelOverrideWrap = document.getElementById('nav-label-override-wrap');
  1054. var labelOverrideInput = document.getElementById('nav_label_override_input');
  1055. ​
  1056. function setType(type) {
  1057. typeInput.value = type;
  1058. categoryFields.style.display = type === 'category' ? '' : 'none';
  1059. pageFields.style.display = type === 'page' ? '' : 'none';
  1060. customFields.style.display = type === 'custom' ? '' : 'none';
  1061. var showOverride = type === 'category' || type === 'page';
  1062. labelOverrideWrap.style.display = showOverride ? '' : 'none';
  1063. labelOverrideInput.style.display = showOverride ? '' : 'none';
  1064. ​
  1065. typeTabs.forEach(function(tab) {
  1066. tab.classList.toggle('active', tab.dataset.navType === type);
  1067. });
  1068. }
  1069. ​
  1070. typeTabs.forEach(function(tab) {
  1071. tab.addEventListener('click', function(event) {
  1072. event.preventDefault();
  1073. setType(this.dataset.navType);
  1074. });
  1075. });
  1076. })();
  1077. </script>
  1078. <?php endif; ?>
  1079. ​
  1080. <?php if ($activeTab === 'search'): ?>
  1081. <form method="post" class="settings-section">
  1082. <?= Csrf::field() ?>
  1083. <input type="hidden" name="action" value="save_settings">
  1084. <input type="hidden" name="settings_tab" value="search">
  1085. ​
  1086. <label><input type="checkbox" name="search_enabled" <?= $settings['search_enabled'] === '1' ? 'checked' : '' ?>> Enable site search (search.php)</label>
  1087. <p style="font-size:12px;color:var(--muted);margin:-6px 0 16px;">When off, search.php is unreachable and no search control is shown anywhere on the site.</p>
  1088. ​
  1089. <h2 class="dash-subtitle"><?= Icons::icon('search', 'icon icon-sm') ?>Search in</h2>
  1090. <label><input type="checkbox" name="search_match_title" <?= $settings['search_match_title'] === '1' ? 'checked' : '' ?>> Product titles</label>
  1091. <label><input type="checkbox" name="search_match_excerpt" <?= $settings['search_match_excerpt'] === '1' ? 'checked' : '' ?>> Product excerpts</label>
  1092. <label><input type="checkbox" name="search_match_content" <?= $settings['search_match_content'] === '1' ? 'checked' : '' ?>> Full product description (block text)</label>
  1093. <p style="font-size:12px;color:var(--muted);margin:-6px 0 16px;">At least one of these should stay checked, otherwise search will return no results.</p>
  1094. ​
  1095. <h2 class="dash-subtitle"><?= Icons::icon('palette', 'icon icon-sm') ?>Appearance</h2>
  1096. <label>Control style</label>
  1097. <select name="search_style">
  1098. <option value="icon" <?= $settings['search_style'] === 'icon' ? 'selected' : '' ?>>Icon (magnifying glass, opens search.php)</option>
  1099. <option value="field" <?= $settings['search_style'] === 'field' ? 'selected' : '' ?>>Text field (typed query, submits to search.php)</option>
  1100. </select>
  1101. ​
  1102. <h2 class="dash-subtitle"><?= Icons::icon('layout', 'icon icon-sm') ?>Placement</h2>
  1103. <label><input type="checkbox" name="search_show_in_nav" <?= $settings['search_show_in_nav'] === '1' ? 'checked' : '' ?>> Show in top navigation</label>
  1104. <label><input type="checkbox" name="search_show_in_footer" <?= $settings['search_show_in_footer'] === '1' ? 'checked' : '' ?>> Show in footer</label>
  1105. <p style="font-size:12px;color:var(--muted);margin:-6px 0 16px;">Both off by default — pick one or both.</p>
  1106. ​
  1107. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1108. </form>
  1109. <?php endif; ?>
  1110. ​
  1111. <?php if ($activeTab === 'reviews'): ?>
  1112. <form method="post" class="settings-section">
  1113. <?= Csrf::field() ?>
  1114. <input type="hidden" name="action" value="save_settings">
  1115. <input type="hidden" name="settings_tab" value="reviews">
  1116. ​
  1117. <label><input type="checkbox" name="reviews_enabled" <?= $settings['reviews_enabled'] === '1' ? 'checked' : '' ?>> Enable product reviews (customers can leave a star rating and a written review; moderate them from the Reviews page in the dashboard)</label>
  1118. ​
  1119. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1120. </form>
  1121. <?php endif; ?>
  1122. ​
  1123. <?php if ($activeTab === 'custom-code'): ?>
  1124. <form method="post" class="settings-section">
  1125. <?= Csrf::field() ?>
  1126. <input type="hidden" name="action" value="save_settings">
  1127. <input type="hidden" name="settings_tab" value="custom-code">
  1128. ​
  1129. <label>Custom CSS</label>
  1130. <textarea name="custom_css" rows="8" class="be-code"><?= htmlspecialchars($settings['custom_css']) ?></textarea>
  1131. ​
  1132. <label>Custom JavaScript</label>
  1133. <textarea name="custom_js" rows="8" class="be-code"><?= htmlspecialchars($settings['custom_js']) ?></textarea>
  1134. ​
  1135. <h2 class="dash-subtitle"><?= Icons::icon('code', 'icon icon-sm') ?>Custom head HTML</h2>
  1136. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 12px;">Raw HTML injected at the end of &lt;head&gt; — meta tags, verification codes, tracking snippets that need to load early.</p>
  1137. <textarea name="custom_head_html" rows="8" class="be-code"><?= htmlspecialchars($settings['custom_head_html']) ?></textarea>
  1138. ​
  1139. <label style="margin-top:12px;">Show on</label>
  1140. <label><input type="checkbox" name="custom_head_html_index" <?= $settings['custom_head_html_index'] === '1' ? 'checked' : '' ?>> Home page</label>
  1141. <label><input type="checkbox" name="custom_head_html_product" <?= $settings['custom_head_html_product'] === '1' ? 'checked' : '' ?>> Products</label>
  1142. <label><input type="checkbox" name="custom_head_html_category" <?= $settings['custom_head_html_category'] === '1' ? 'checked' : '' ?>> Categories</label>
  1143. <label><input type="checkbox" name="custom_head_html_pages" <?= $settings['custom_head_html_pages'] === '1' ? 'checked' : '' ?>> Pages</label>
  1144. ​
  1145. <h2 class="dash-subtitle"><?= Icons::icon('code', 'icon icon-sm') ?>Custom body HTML</h2>
  1146. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 12px;">Raw HTML injected at the end of &lt;body&gt; — chat widgets, analytics pixels, and other scripts that don't need to block rendering.</p>
  1147. <textarea name="custom_body_html" rows="8" class="be-code"><?= htmlspecialchars($settings['custom_body_html']) ?></textarea>
  1148. ​
  1149. <label style="margin-top:12px;">Show on</label>
  1150. <label><input type="checkbox" name="custom_body_html_index" <?= $settings['custom_body_html_index'] === '1' ? 'checked' : '' ?>> Home page</label>
  1151. <label><input type="checkbox" name="custom_body_html_product" <?= $settings['custom_body_html_product'] === '1' ? 'checked' : '' ?>> Products</label>
  1152. <label><input type="checkbox" name="custom_body_html_category" <?= $settings['custom_body_html_category'] === '1' ? 'checked' : '' ?>> Categories</label>
  1153. <label><input type="checkbox" name="custom_body_html_pages" <?= $settings['custom_body_html_pages'] === '1' ? 'checked' : '' ?>> Pages</label>
  1154. ​
  1155. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1156. </form>
  1157. <?php endif; ?>
  1158. ​
  1159. <?php if ($activeTab === 'email'): ?>
  1160. <form method="post" class="settings-section">
  1161. <?= Csrf::field() ?>
  1162. <input type="hidden" name="action" value="save_settings">
  1163. <input type="hidden" name="settings_tab" value="email">
  1164. ​
  1165. <label>Send transactional email using</label>
  1166. <select name="mail_transport" id="mail_transport_select">
  1167. <option value="smtp" <?= $settings['mail_transport'] === 'smtp' ? 'selected' : '' ?>>SMTP</option>
  1168. <option value="resend" <?= $settings['mail_transport'] === 'resend' ? 'selected' : '' ?>>Resend</option>
  1169. <option value="postmark" <?= $settings['mail_transport'] === 'postmark' ? 'selected' : '' ?>>Postmark</option>
  1170. </select>
  1171. ​
  1172. <div id="mail-smtp-fields">
  1173. <h2 class="dash-subtitle"><?= Icons::icon('mail', 'icon icon-sm') ?>SMTP</h2>
  1174. <div class="settings-grid">
  1175. <div>
  1176. <label>Host</label>
  1177. <input type="text" name="mail_smtp_host" value="<?= htmlspecialchars($settings['mail_smtp_host']) ?>" placeholder="smtp.example.com">
  1178. </div>
  1179. <div>
  1180. <label>Port</label>
  1181. <input type="number" name="mail_smtp_port" min="1" max="65535" value="<?= (int) $settings['mail_smtp_port'] ?>">
  1182. </div>
  1183. </div>
  1184. ​
  1185. <label>Username</label>
  1186. <input type="text" name="mail_smtp_username" value="<?= htmlspecialchars($settings['mail_smtp_username']) ?>" autocomplete="off">
  1187. ​
  1188. <label>Password</label>
  1189. <input type="password" name="mail_smtp_password" value="<?= htmlspecialchars($settings['mail_smtp_password']) ?>" autocomplete="new-password">
  1190. ​
  1191. <label>Encryption</label>
  1192. <select name="mail_smtp_encryption">
  1193. <option value="tls" <?= $settings['mail_smtp_encryption'] === 'tls' ? 'selected' : '' ?>>STARTTLS</option>
  1194. <option value="ssl" <?= $settings['mail_smtp_encryption'] === 'ssl' ? 'selected' : '' ?>>SSL</option>
  1195. <option value="none" <?= $settings['mail_smtp_encryption'] === 'none' ? 'selected' : '' ?>>None</option>
  1196. </select>
  1197. </div>
  1198. ​
  1199. <div id="mail-resend-fields">
  1200. <h2 class="dash-subtitle"><?= Icons::icon('mail', 'icon icon-sm') ?>Resend</h2>
  1201. <label>API key</label>
  1202. <input type="password" name="mail_resend_api_key" value="<?= htmlspecialchars($settings['mail_resend_api_key']) ?>" autocomplete="new-password" placeholder="re_...">
  1203. </div>
  1204. ​
  1205. <div id="mail-postmark-fields">
  1206. <h2 class="dash-subtitle"><?= Icons::icon('mail', 'icon icon-sm') ?>Postmark</h2>
  1207. <label>Server token</label>
  1208. <input type="password" name="mail_postmark_server_token" value="<?= htmlspecialchars($settings['mail_postmark_server_token']) ?>" autocomplete="new-password">
  1209. </div>
  1210. ​
  1211. <script>
  1212. (function() {
  1213. var select = document.getElementById('mail_transport_select');
  1214. var groups = {
  1215. smtp: document.getElementById('mail-smtp-fields'),
  1216. resend: document.getElementById('mail-resend-fields'),
  1217. postmark: document.getElementById('mail-postmark-fields')
  1218. };
  1219. ​
  1220. function apply() {
  1221. Object.keys(groups).forEach(function(key) {
  1222. groups[key].style.display = select.value === key ? '' : 'none';
  1223. });
  1224. }
  1225. ​
  1226. select.addEventListener('change', apply);
  1227. apply();
  1228. })();
  1229. </script>
  1230. ​
  1231. <h2 class="dash-subtitle"><?= Icons::icon('mail', 'icon icon-sm') ?>Sender</h2>
  1232. <label>From email</label>
  1233. <input type="email" name="mail_from_email" value="<?= htmlspecialchars($settings['mail_from_email']) ?>" placeholder="orders@example.com">
  1234. ​
  1235. <label>From name</label>
  1236. <input type="text" name="mail_from_name" value="<?= htmlspecialchars($settings['mail_from_name']) ?>" placeholder="<?= htmlspecialchars($settings['site_name']) ?>">
  1237. ​
  1238. <label>Reply-to (optional)</label>
  1239. <input type="email" name="mail_reply_to" value="<?= htmlspecialchars($settings['mail_reply_to']) ?>">
  1240. ​
  1241. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1242. </form>
  1243. ​
  1244. <div class="sidebar-box" style="max-width:520px;margin-top:20px;">
  1245. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('mail', 'icon icon-sm') ?>Send a test email</h2>
  1246. <p style="font-size:12.5px;color:var(--muted);margin:0 0 16px;">Sends a short test message to your own team account email<?= !empty($currentUser['email']) ? ' (' . htmlspecialchars($currentUser['email']) . ')' : '' ?> using the settings saved above — save first.</p>
  1247. <form method="post">
  1248. <?= Csrf::field() ?>
  1249. <input type="hidden" name="action" value="send_test_email">
  1250. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('mail', 'icon icon-sm') ?>Send test email</button>
  1251. </form>
  1252. </div>
  1253. <?php endif; ?>
  1254. ​
  1255. <?php if ($activeTab === 'footer'): ?>
  1256. <form method="post" class="settings-section" id="footer-form">
  1257. <?= Csrf::field() ?>
  1258. <input type="hidden" name="action" value="save_footer">
  1259. <input type="hidden" name="footer_columns_json" id="footer_columns_json_input">
  1260. <input type="hidden" name="footer_social_json" id="footer_social_json_input">
  1261. ​
  1262. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('menu-list', 'icon icon-sm') ?>Footer link columns</h2>
  1263. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Up to three columns, each with a title and a list of links, shown at the bottom of the site.</p>
  1264. <div id="footer-columns-root"></div>
  1265. <button type="button" id="footer-add-column" class="dash-btn dash-btn-small" style="margin-top:4px;"><?= Icons::icon('plus', 'icon icon-sm') ?>Add column</button>
  1266. ​
  1267. <h2 class="dash-subtitle"><?= Icons::icon('globe', 'icon icon-sm') ?>Social / media icons</h2>
  1268. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Pick from the built-in icons or add a custom one with your own SVG.</p>
  1269. <div id="footer-social-root"></div>
  1270. <div class="publish-actions" style="margin-top:4px;">
  1271. <select id="footer-add-social-select" class="dash-input-inline" style="width:auto;">
  1272. <?php foreach (SocialIcons::builtinLabels() as $key => $label): ?>
  1273. <option value="<?= htmlspecialchars($key) ?>"><?= htmlspecialchars($label) ?></option>
  1274. <?php endforeach; ?>
  1275. </select>
  1276. <button type="button" id="footer-add-social-builtin" class="dash-btn dash-btn-small"><?= Icons::icon('plus', 'icon icon-sm') ?>Add icon</button>
  1277. <button type="button" id="footer-add-social-custom" class="dash-btn dash-btn-small"><?= Icons::icon('plus', 'icon icon-sm') ?>Add custom SVG icon</button>
  1278. </div>
  1279. ​
  1280. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1281. </form>
  1282. ​
  1283. <script>
  1284. (function() {
  1285. var initialColumns = <?= str_replace('<', '\u003C', FooterLinks::sanitizeColumns($settings['footer_columns'])) ?>;
  1286. var initialSocial = <?= str_replace('<', '\u003C', FooterLinks::sanitizeSocialLinks($settings['footer_social_links'])) ?>;
  1287. ​
  1288. var columnsRoot = document.getElementById('footer-columns-root');
  1289. var socialRoot = document.getElementById('footer-social-root');
  1290. ​
  1291. function el(tag, cls, text) {
  1292. var e = document.createElement(tag);
  1293. if (cls) e.className = cls;
  1294. if (text !== undefined) e.textContent = text;
  1295. return e;
  1296. }
  1297. ​
  1298. function renderColumns() {
  1299. columnsRoot.innerHTML = '';
  1300. initialColumns.forEach(function(column, colIndex) {
  1301. var box = el('div', 'sidebar-box');
  1302. box.style.marginBottom = '12px';
  1303. ​
  1304. var headerRow = el('div', 'be-list-row');
  1305. headerRow.style.marginBottom = '0';
  1306. ​
  1307. var titleLabel = el('label', null, 'Column title');
  1308. titleLabel.style.marginTop = '0';
  1309. titleLabel.style.marginBottom = '0';
  1310. titleLabel.style.flex = '1';
  1311. headerRow.appendChild(titleLabel);
  1312. ​
  1313. var moveBtnGroup = el('div', 'be-list-row');
  1314. moveBtnGroup.style.marginBottom = '0';
  1315. moveBtnGroup.style.gap = '8px';
  1316. moveBtnGroup.style.flexShrink = '0';
  1317. ​
  1318. if (colIndex > 0) {
  1319. var moveUpBtn = document.createElement('button');
  1320. moveUpBtn.type = 'button';
  1321. moveUpBtn.className = 'dash-btn-small';
  1322. moveUpBtn.setAttribute('aria-label', 'Move column up');
  1323. moveUpBtn.innerHTML = '<svg class="icon icon-sm" aria-hidden="true"><use href="#icon-arrow-up"></use></svg>';
  1324. moveUpBtn.addEventListener('click', function() {
  1325. var tmp = initialColumns[colIndex - 1];
  1326. initialColumns[colIndex - 1] = initialColumns[colIndex];
  1327. initialColumns[colIndex] = tmp;
  1328. renderColumns();
  1329. });
  1330. moveBtnGroup.appendChild(moveUpBtn);
  1331. }
  1332. ​
  1333. if (colIndex < initialColumns.length - 1) {
  1334. var moveDownBtn = document.createElement('button');
  1335. moveDownBtn.type = 'button';
  1336. moveDownBtn.className = 'dash-btn-small';
  1337. moveDownBtn.setAttribute('aria-label', 'Move column down');
  1338. moveDownBtn.innerHTML = '<svg class="icon icon-sm" aria-hidden="true"><use href="#icon-arrow-down"></use></svg>';
  1339. moveDownBtn.addEventListener('click', function() {
  1340. var tmp = initialColumns[colIndex + 1];
  1341. initialColumns[colIndex + 1] = initialColumns[colIndex];
  1342. initialColumns[colIndex] = tmp;
  1343. renderColumns();
  1344. });
  1345. moveBtnGroup.appendChild(moveDownBtn);
  1346. }
  1347. ​
  1348. headerRow.appendChild(moveBtnGroup);
  1349. box.appendChild(headerRow);
  1350. ​
  1351. var titleInput = document.createElement('input');
  1352. titleInput.type = 'text';
  1353. titleInput.value = column.title || '';
  1354. titleInput.placeholder = 'TITLE';
  1355. titleInput.addEventListener('input', function() {
  1356. column.title = this.value;
  1357. });
  1358. box.appendChild(titleInput);
  1359. ​
  1360. var linksLabel = el('label', null, 'Links');
  1361. box.appendChild(linksLabel);
  1362. ​
  1363. (column.links || []).forEach(function(link, linkIndex) {
  1364. var row = el('div', 'be-list-row');
  1365. ​
  1366. var labelInput = document.createElement('input');
  1367. labelInput.type = 'text';
  1368. labelInput.placeholder = 'Label';
  1369. labelInput.value = link.label || '';
  1370. labelInput.style.flex = '1';
  1371. labelInput.addEventListener('input', function() {
  1372. link.label = this.value;
  1373. });
  1374. ​
  1375. var urlInput = document.createElement('input');
  1376. urlInput.type = 'text';
  1377. urlInput.placeholder = 'https:// or /page';
  1378. urlInput.value = link.url || '';
  1379. urlInput.style.flex = '1';
  1380. urlInput.addEventListener('input', function() {
  1381. link.url = this.value;
  1382. });
  1383. ​
  1384. var linkMoveBtnGroup = el('div', 'be-list-row');
  1385. linkMoveBtnGroup.style.marginBottom = '0';
  1386. linkMoveBtnGroup.style.gap = '8px';
  1387. linkMoveBtnGroup.style.flexShrink = '0';
  1388. ​
  1389. if (linkIndex > 0) {
  1390. var linkMoveUpBtn = document.createElement('button');
  1391. linkMoveUpBtn.type = 'button';
  1392. linkMoveUpBtn.className = 'dash-btn-small';
  1393. linkMoveUpBtn.setAttribute('aria-label', 'Move link up');
  1394. linkMoveUpBtn.innerHTML = '<svg class="icon icon-sm" aria-hidden="true"><use href="#icon-arrow-up"></use></svg>';
  1395. linkMoveUpBtn.addEventListener('click', function() {
  1396. var tmp = column.links[linkIndex - 1];
  1397. column.links[linkIndex - 1] = column.links[linkIndex];
  1398. column.links[linkIndex] = tmp;
  1399. renderColumns();
  1400. });
  1401. linkMoveBtnGroup.appendChild(linkMoveUpBtn);
  1402. }
  1403. ​
  1404. if (linkIndex < column.links.length - 1) {
  1405. var linkMoveDownBtn = document.createElement('button');
  1406. linkMoveDownBtn.type = 'button';
  1407. linkMoveDownBtn.className = 'dash-btn-small';
  1408. linkMoveDownBtn.setAttribute('aria-label', 'Move link down');
  1409. linkMoveDownBtn.innerHTML = '<svg class="icon icon-sm" aria-hidden="true"><use href="#icon-arrow-down"></use></svg>';
  1410. linkMoveDownBtn.addEventListener('click', function() {
  1411. var tmp = column.links[linkIndex + 1];
  1412. column.links[linkIndex + 1] = column.links[linkIndex];
  1413. column.links[linkIndex] = tmp;
  1414. renderColumns();
  1415. });
  1416. linkMoveBtnGroup.appendChild(linkMoveDownBtn);
  1417. }
  1418. ​
  1419. var removeBtn = document.createElement('button');
  1420. removeBtn.type = 'button';
  1421. removeBtn.className = 'dash-btn-small dash-btn-danger';
  1422. removeBtn.textContent = '×';
  1423. removeBtn.addEventListener('click', function() {
  1424. column.links.splice(linkIndex, 1);
  1425. renderColumns();
  1426. });
  1427. ​
  1428. row.appendChild(labelInput);
  1429. row.appendChild(urlInput);
  1430. row.appendChild(linkMoveBtnGroup);
  1431. row.appendChild(removeBtn);
  1432. box.appendChild(row);
  1433. });
  1434. ​
  1435. var addLinkBtn = document.createElement('button');
  1436. addLinkBtn.type = 'button';
  1437. addLinkBtn.className = 'dash-btn-small';
  1438. addLinkBtn.textContent = 'Add link';
  1439. addLinkBtn.addEventListener('click', function() {
  1440. column.links = column.links || [];
  1441. column.links.push({
  1442. label: '',
  1443. url: ''
  1444. });
  1445. renderColumns();
  1446. });
  1447. box.appendChild(addLinkBtn);
  1448. ​
  1449. var removeColumnBtn = document.createElement('button');
  1450. removeColumnBtn.type = 'button';
  1451. removeColumnBtn.className = 'dash-btn-small dash-btn-danger';
  1452. removeColumnBtn.textContent = 'Remove column';
  1453. removeColumnBtn.style.marginLeft = '8px';
  1454. removeColumnBtn.addEventListener('click', function() {
  1455. initialColumns.splice(colIndex, 1);
  1456. renderColumns();
  1457. });
  1458. box.appendChild(removeColumnBtn);
  1459. ​
  1460. columnsRoot.appendChild(box);
  1461. });
  1462. }
  1463. ​
  1464. function renderSocial() {
  1465. socialRoot.innerHTML = '';
  1466. initialSocial.forEach(function(item, index) {
  1467. var row = el('div', 'be-list-row');
  1468. ​
  1469. var iconLabel = el('span', 'be-block-type', item.key === 'custom' ? 'CUSTOM' : item.key.toUpperCase());
  1470. iconLabel.style.width = '90px';
  1471. iconLabel.style.flexShrink = '0';
  1472. ​
  1473. if (item.key === 'custom') {
  1474. var nameInput = document.createElement('input');
  1475. nameInput.type = 'text';
  1476. nameInput.placeholder = 'Label';
  1477. nameInput.value = item.label || '';
  1478. nameInput.style.flex = '1';
  1479. nameInput.addEventListener('input', function() {
  1480. item.label = this.value;
  1481. });
  1482. row.appendChild(iconLabel);
  1483. row.appendChild(nameInput);
  1484. } else {
  1485. row.appendChild(iconLabel);
  1486. }
  1487. ​
  1488. var urlInput = document.createElement('input');
  1489. urlInput.type = 'text';
  1490. urlInput.placeholder = 'https://...';
  1491. urlInput.value = item.url || '';
  1492. urlInput.style.flex = '1';
  1493. urlInput.addEventListener('input', function() {
  1494. item.url = this.value;
  1495. });
  1496. row.appendChild(urlInput);
  1497. ​
  1498. var removeBtn = document.createElement('button');
  1499. removeBtn.type = 'button';
  1500. removeBtn.className = 'dash-btn-small dash-btn-danger';
  1501. removeBtn.textContent = '×';
  1502. removeBtn.addEventListener('click', function() {
  1503. initialSocial.splice(index, 1);
  1504. renderSocial();
  1505. });
  1506. row.appendChild(removeBtn);
  1507. ​
  1508. socialRoot.appendChild(row);
  1509. ​
  1510. if (item.key === 'custom') {
  1511. var svgLabel = el('label', null, 'SVG code');
  1512. svgLabel.style.margin = '2px 0 4px';
  1513. var svgTextarea = document.createElement('textarea');
  1514. svgTextarea.rows = 2;
  1515. svgTextarea.className = 'be-code';
  1516. svgTextarea.style.minHeight = '50px';
  1517. svgTextarea.value = item.svg || '';
  1518. svgTextarea.placeholder = '<svg viewBox="0 0 24 24">...</svg>';
  1519. svgTextarea.addEventListener('input', function() {
  1520. item.svg = this.value;
  1521. });
  1522. socialRoot.appendChild(svgLabel);
  1523. socialRoot.appendChild(svgTextarea);
  1524. }
  1525. });
  1526. }
  1527. ​
  1528. document.getElementById('footer-add-column').addEventListener('click', function() {
  1529. if (initialColumns.length >= 3) {
  1530. alert('You can only have up to three footer columns.');
  1531. return;
  1532. }
  1533. initialColumns.push({
  1534. title: '',
  1535. links: [{
  1536. label: '',
  1537. url: ''
  1538. }]
  1539. });
  1540. renderColumns();
  1541. });
  1542. ​
  1543. document.getElementById('footer-add-social-builtin').addEventListener('click', function() {
  1544. var select = document.getElementById('footer-add-social-select');
  1545. initialSocial.push({
  1546. key: select.value,
  1547. label: select.options[select.selectedIndex].text,
  1548. url: ''
  1549. });
  1550. renderSocial();
  1551. });
  1552. ​
  1553. document.getElementById('footer-add-social-custom').addEventListener('click', function() {
  1554. initialSocial.push({
  1555. key: 'custom',
  1556. label: '',
  1557. url: '',
  1558. svg: ''
  1559. });
  1560. renderSocial();
  1561. });
  1562. ​
  1563. document.getElementById('footer-form').addEventListener('submit', function() {
  1564. document.getElementById('footer_columns_json_input').value = JSON.stringify(initialColumns);
  1565. document.getElementById('footer_social_json_input').value = JSON.stringify(initialSocial);
  1566. });
  1567. ​
  1568. renderColumns();
  1569. renderSocial();
  1570. })();
  1571. </script>
  1572. <?php endif; ?>
  1573. ​
  1574. <?php if ($activeTab === 'apps'): ?>
  1575. <form method="post" class="settings-section">
  1576. <?= Csrf::field() ?>
  1577. <input type="hidden" name="action" value="save_settings">
  1578. <input type="hidden" name="settings_tab" value="apps">
  1579. ​
  1580. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('smartphone', 'icon icon-sm') ?>Progressive Web App</h2>
  1581. <label><input type="checkbox" name="pwa_enabled" <?= $settings['pwa_enabled'] === '1' ? 'checked' : '' ?>> Make this site installable as a Progressive Web App</label>
  1582. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Adds a web manifest and a service worker so visitors can add the site to their home screen.</p>
  1583. ​
  1584. <label>App name (leave empty to use the site name)</label>
  1585. <input type="text" name="pwa_name" value="<?= htmlspecialchars($settings['pwa_name']) ?>" placeholder="<?= htmlspecialchars($settings['site_name']) ?>">
  1586. ​
  1587. <label>Short name (used under the home screen icon, leave empty to use the app name)</label>
  1588. <input type="text" name="pwa_short_name" value="<?= htmlspecialchars($settings['pwa_short_name']) ?>" maxlength="12" placeholder="<?= htmlspecialchars($settings['site_name']) ?>">
  1589. ​
  1590. <label>Description (leave empty to use the site description)</label>
  1591. <textarea name="pwa_description" rows="2" placeholder="<?= htmlspecialchars($settings['site_description']) ?>"><?= htmlspecialchars($settings['pwa_description']) ?></textarea>
  1592. ​
  1593. <label>Icon URL (leave empty to use the site logo)</label>
  1594. <input type="text" name="pwa_icon_url" data-image-upload value="<?= htmlspecialchars($settings['pwa_icon_url']) ?>" placeholder="media/img/pwa-icon.png">
  1595. <p style="font-size:12px;color:var(--muted);margin-top:6px;">A square image, at least 512×512, works best.</p>
  1596. ​
  1597. <div class="settings-grid">
  1598. <div>
  1599. <label>Theme color</label>
  1600. <input type="color" name="pwa_theme_color" value="<?= htmlspecialchars($settings['pwa_theme_color']) ?>">
  1601. </div>
  1602. <div>
  1603. <label>Background color</label>
  1604. <input type="color" name="pwa_background_color" value="<?= htmlspecialchars($settings['pwa_background_color']) ?>">
  1605. </div>
  1606. </div>
  1607. ​
  1608. <h2 class="dash-subtitle"><?= Icons::icon('link', 'icon icon-sm') ?>Mobile app links</h2>
  1609. <label><input type="checkbox" name="app_banner_enabled" <?= $settings['app_banner_enabled'] === '1' ? 'checked' : '' ?>> Show a banner prompting mobile visitors to get the app</label>
  1610. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Automatically detects Android or iOS and links to the matching store — hidden on desktop and when the matching store URL is empty.</p>
  1611. ​
  1612. <label>Google Play URL (Android)</label>
  1613. <input type="text" name="app_android_url" value="<?= htmlspecialchars($settings['app_android_url']) ?>" placeholder="https://play.google.com/store/apps/details?id=...">
  1614. ​
  1615. <label>App Store URL (iOS)</label>
  1616. <input type="text" name="app_ios_url" value="<?= htmlspecialchars($settings['app_ios_url']) ?>" placeholder="https://apps.apple.com/app/id...">
  1617. ​
  1618. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1619. </form>
  1620. <?php endif; ?>
  1621. ​
  1622. <?php if ($activeTab === 'ai'): ?>
  1623. <?php
  1624. $aiSettings = Ai::settings();
  1625. $aiRoleLabels = [];
  1626. foreach (Ai::roleKeys() as $aiRoleKey) {
  1627. $aiRoleLabels[$aiRoleKey] = Auth::roleLabel($aiRoleKey);
  1628. }
  1629. ?>
  1630. <form method="post" class="settings-section" id="ai-settings-form">
  1631. <?= Csrf::field() ?>
  1632. <input type="hidden" name="action" value="save_settings">
  1633. <input type="hidden" name="settings_tab" value="ai">
  1634. ​
  1635. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 20px;">An AI chat helper in the product and page editors. It is off by default. When someone uses it, the text of the product they are editing is sent to the provider you choose below. The AI never saves anything by itself: every change is shown as a proposal that the editor must apply, and each applied change can be undone.</p>
  1636. ​
  1637. <label><input type="checkbox" name="ai_enabled" <?= $aiSettings['enabled'] ? 'checked' : '' ?>> Enable the AI helper</label>
  1638. ​
  1639. <label>Provider</label>
  1640. <select name="ai_provider" id="ai-provider-select">
  1641. <?php foreach (Ai::PROVIDERS as $aiProviderKey => $aiProviderLabel): ?>
  1642. <option value="<?= htmlspecialchars($aiProviderKey) ?>" <?= $aiSettings['provider'] === $aiProviderKey ? 'selected' : '' ?>><?= htmlspecialchars($aiProviderLabel) ?></option>
  1643. <?php endforeach; ?>
  1644. </select>
  1645. ​
  1646. <?php foreach (Ai::PROVIDERS as $aiProviderKey => $aiProviderLabel): ?>
  1647. <?php
  1648. $aiKeyState = Ai::keyState($aiProviderKey);
  1649. $aiCurrentModel = $aiSettings['models'][$aiProviderKey];
  1650. $aiIsPreset = isset(Ai::MODELS[$aiProviderKey][$aiCurrentModel]);
  1651. ?>
  1652. <div class="sidebar-box ai-provider-panel" data-provider="<?= htmlspecialchars($aiProviderKey) ?>" style="margin-top:16px;">
  1653. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('sparkles', 'icon icon-sm') ?><?= htmlspecialchars($aiProviderLabel) ?></h2>
  1654. ​
  1655. <label>Model</label>
  1656. <select name="ai_model_<?= htmlspecialchars($aiProviderKey) ?>" class="ai-model-select">
  1657. <?php foreach (Ai::MODELS[$aiProviderKey] as $aiModelId => $aiModelLabel): ?>
  1658. <option value="<?= htmlspecialchars($aiModelId) ?>" <?= $aiCurrentModel === $aiModelId ? 'selected' : '' ?>><?= htmlspecialchars($aiModelLabel) ?> (<?= htmlspecialchars($aiModelId) ?>)</option>
  1659. <?php endforeach; ?>
  1660. <option value="custom" <?= $aiIsPreset ? '' : 'selected' ?>>Other model ID…</option>
  1661. </select>
  1662. <input type="text" name="ai_model_<?= htmlspecialchars($aiProviderKey) ?>_custom" class="ai-model-custom" value="<?= $aiIsPreset ? '' : htmlspecialchars($aiCurrentModel) ?>" placeholder="model-id" maxlength="80" style="margin-top:8px;<?= $aiIsPreset ? 'display:none;' : '' ?>">
  1663. ​
  1664. <label>API key</label>
  1665. <input type="password" name="ai_key_<?= htmlspecialchars($aiProviderKey) ?>" autocomplete="new-password" spellcheck="false" placeholder="<?= $aiKeyState['hint'] !== null ? 'Saved key ending in ' . htmlspecialchars($aiKeyState['hint']) . ' — leave blank to keep it' : 'Paste the API key' ?>">
  1666. <?php if ($aiKeyState['environment']): ?>
  1667. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">A key is also set in the server environment file (AI_KEY_<?= strtoupper(htmlspecialchars($aiProviderKey)) ?>) and takes priority.</p>
  1668. <?php endif; ?>
  1669. <?php if ($aiKeyState['hint'] !== null): ?>
  1670. <label><input type="checkbox" name="ai_key_remove_<?= htmlspecialchars($aiProviderKey) ?>"> Remove the saved key</label>
  1671. <?php endif; ?>
  1672. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">The key is stored encrypted and is never shown again or sent to the browser.</p>
  1673. </div>
  1674. <?php endforeach; ?>
  1675. ​
  1676. <h2 class="dash-subtitle"><?= Icons::icon('toggle', 'icon icon-sm') ?>Functions</h2>
  1677. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 12px;">The AI can only do what is ticked here, even if a user asks for more.</p>
  1678. <?php foreach (Ai::FEATURES as $aiFeatureKey => [$aiFeatureLabel, $aiFeatureHelp]): ?>
  1679. <label><input type="checkbox" name="ai_features[]" value="<?= htmlspecialchars($aiFeatureKey) ?>" <?= in_array($aiFeatureKey, $aiSettings['features'], true) ? 'checked' : '' ?>> <span><strong><?= htmlspecialchars($aiFeatureLabel) ?></strong> — <?= htmlspecialchars($aiFeatureHelp) ?></span></label>
  1680. <?php endforeach; ?>
  1681. ​
  1682. <h2 class="dash-subtitle"><?= Icons::icon('users', 'icon icon-sm') ?>Who can use it</h2>
  1683. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 12px;">The Super Admin always has access. Each person can also hide the helper for themselves in Interface.</p>
  1684. <?php foreach ($aiRoleLabels as $aiRoleKey => $aiRoleLabel): ?>
  1685. <label><input type="checkbox" name="ai_roles[]" value="<?= htmlspecialchars($aiRoleKey) ?>" <?= in_array($aiRoleKey, $aiSettings['roles'], true) ? 'checked' : '' ?>> <?= htmlspecialchars($aiRoleLabel) ?></label>
  1686. <?php endforeach; ?>
  1687. ​
  1688. <h2 class="dash-subtitle"><?= Icons::icon('shield', 'icon icon-sm') ?>Limits</h2>
  1689. <div class="settings-grid">
  1690. <div>
  1691. <label>Requests per user per hour</label>
  1692. <input type="number" name="ai_rate_limit" min="1" max="1000" value="<?= (int) $aiSettings['rate_limit'] ?>">
  1693. </div>
  1694. <div>
  1695. <label>Max output tokens</label>
  1696. <input type="number" name="ai_max_tokens" min="256" max="32000" value="<?= (int) $aiSettings['max_tokens'] ?>">
  1697. </div>
  1698. </div>
  1699. ​
  1700. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1701. </form>
  1702. ​
  1703. <script>
  1704. (function() {
  1705. var providerSelect = document.getElementById('ai-provider-select');
  1706. var panels = document.querySelectorAll('.ai-provider-panel');
  1707. ​
  1708. function showProvider() {
  1709. panels.forEach(function(panel) {
  1710. panel.style.display = panel.dataset.provider === providerSelect.value ? '' : 'none';
  1711. });
  1712. }
  1713. ​
  1714. providerSelect.addEventListener('change', showProvider);
  1715. showProvider();
  1716. ​
  1717. document.querySelectorAll('.ai-model-select').forEach(function(select) {
  1718. var custom = select.parentNode.querySelector('.ai-model-custom');
  1719. ​
  1720. function toggleCustom() {
  1721. custom.style.display = select.value === 'custom' ? '' : 'none';
  1722. }
  1723. ​
  1724. select.addEventListener('change', toggleCustom);
  1725. toggleCustom();
  1726. });
  1727. })();
  1728. </script>
  1729. <?php endif; ?>
  1730. ​
  1731. <?php if ($activeTab === 'language'): ?>
  1732. <form method="post" class="settings-section">
  1733. <?= Csrf::field() ?>
  1734. <input type="hidden" name="action" value="save_language">
  1735. <?php foreach ($languageStrings as $key => $value): ?>
  1736. <div class="lang-row">
  1737. <div class="lang-row-key"><?= htmlspecialchars($key) ?></div>
  1738. <input type="text" name="lang_<?= htmlspecialchars($key) ?>" value="<?= htmlspecialchars($value) ?>">
  1739. </div>
  1740. <?php endforeach; ?>
  1741. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save language</button>
  1742. </form>
  1743. <?php endif; ?>
  1744. ​
  1745. <?php require __DIR__ . '/includes/dash-footer.php'; ?>