Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
78 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "adakash26434/akashdigital-wp", "files": {"includes/helpers.php": {"b3b6e2caa71fb31b0e2341a0671bbd5a493bc97d": {"t": 1786068385.1102471, "n": 75733, "h": "221460a137201e2d", "c": "<?php\n// PHP 8 polyfills for compatibility with PHP 7.x\nif (!function_exists('str_starts_with')) {\n function str_starts_with(string $haystack, string $needle): bool {\n return strpos($haystack, $needle) === 0;\n }\n}\nif (!function_exists('str_ends_with')) {\n function str_ends_with(string $haystack, string $needle): bool {\n return $needle === '' || substr($haystack, -strlen($needle)) === $needle;\n }\n}\nif (!function_exists('str_contains')) {\n function str_contains(string $haystack, string $needle): bool {\n return strpos($haystack, $needle) !== false;\n }\n}\n\nfunction post(string $key, mixed $default = ''): mixed {\n return $_POST[$key] ?? $default;\n}\n\nfunction safeOne(string $sql, array $p = []): ?array {\n try { $r = queryOne($sql, $p); return $r ?: null; } catch (\\Throwable $e) { return null; }\n}\n\nfunction safeInt(string $sql, array $params = []): int {\n try {\n $row = queryOne($sql, $params);\n return (int)(is_array($row) ? (reset($row) ?? 0) : ($row ?? 0));\n } catch (\\Throwable $e) {\n error_log('[safeInt] ' . $e->getMessage());\n return 0;\n }\n}\n\nfunction redirectSelf(array $extra = []): void {\n $url = strtok($_SERVER['REQUEST_URI'] ?? '/', '?');\n $params = array_merge($_GET ?? [], $extra);\n if ($params) $url .= '?' . http_build_query($params);\n redirect($url);\n}\n\nfunction e($v): string {\n return htmlspecialchars((string)($v ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');\n}\n\n// नेपालीमा: Asset (CSS/JS/image) ko full URL banaune\nfunction asset(string $path): string {\n return rtrim(SITE_URL, '/') . '/assets/' . ltrim($path, '/');\n}\n\n// नेपालीमा: Site relative path lai full URL banaune\nfunction url(string $path): string {\n return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');\n}\n\n// नेपालीमा: Browser lai arko URL ma pathaune\nfunction redirect(string $path): void {\n header(\"Location: \" . url($path));\n exit;\n}\n\n// नेपालीमा: Flash message session ma store garne\nfunction setFlash(string $key, string $msg): void {\n if (session_status() === PHP_SESSION_NONE) session_start();\n $_SESSION['flash'][$key] = $msg;\n}\n\n// Legacy alias\nfunction flash(string $key, string $msg): void { setFlash($key, $msg); }\n\n// नेपालीमा: Flash message read garera mitaune (one-shot)\nfunction getFlash(string $key): ?string {\n if (session_status() === PHP_SESSION_NONE) session_start();\n $msg = $_SESSION['flash'][$key] ?? null;\n unset($_SESSION['flash'][$key]);\n return $msg;\n}\n\n// ── Site settings from key-value table ─────────────────────────\nfunction siteSettings(bool $refresh = false): array {\n static $cache = null;\n if ($refresh) $cache = null;\n if ($cache !== null) return $cache;\n $defaults = [\n 'site_name' => SITE_NAME,\n 'site_tagline' => 'Cooperative Software for Nepal',\n 'logo_url' => null,\n 'favicon_url' => null,\n 'company_name' => SITE_NAME,\n 'company_logo_url' => null,\n 'contact_email' => '',\n 'contact_phone' => '',\n 'address' => '',\n 'social_links' => [],\n 'whatsapp_number' => null,\n 'whatsapp_enabled' => true,\n 'whatsapp_message' => \"Hello! I'm interested in your software.\",\n 'whatsapp_label' => 'Support WhatsApp',\n 'maintenance_mode' => false,\n ];\n // Try to get site settings, but gracefully fall back to defaults if DB is unavailable\n try {\n $rows = @query(\"SELECT setting_key, setting_val FROM site_settings\");\n if (!empty($rows)) {\n $map = [];\n foreach ($rows as $r) $map[$r['setting_key']] = $r['setting_val'];\n if (isset($map['social_links'])) {\n $map['social_links'] = json_decode($map['social_links'], true) ?? [];\n }\n $cache = array_merge($defaults, $map);\n return $cache;\n }\n } catch (\\Throwable $e) { \n error_log('[helpers.php] siteSettings query failed: ' . $e->getMessage()); \n }\n $cache = $defaults;\n return $cache;\n} // यहाँ Bracket बन्द गरिएको छ\n\n/**\n * Homepage trust strip — cooperative clients / partners / provinces.\n * Single source for index.php, partners.php, and any other public surface.\n * Client count = unique(active CRM clients ∪ partners table) + admin offset.\n */\nfunction siteTrustStats(?array $settings = null): array {\n $s = $settings ?? siteSettings();\n $offsetRaw = $s['client_count_offset'] ?? '';\n $offset = ($offsetRaw !== '' && $offsetRaw !== null) ? (int)$offsetRaw : 300;\n\n $crm = [];\n try {\n $crm = query(\n \"SELECT org_name, logo_url, district, province FROM clients\n WHERE LOWER(TRIM(status)) = 'active'\n AND TRIM(org_name) IS NOT NULL AND TRIM(org_name) != ''\n ORDER BY org_name ASC\"\n );\n } catch (\\Throwable $e) {\n error_log('[helpers] siteTrustStats clients: ' . $e->getMessage());\n }\n\n $partners = [];\n try {\n $partners = query(\n \"SELECT name AS org_name, logo_url, district, '' AS province\n FROM partners ORDER BY position ASC, id ASC\"\n );\n } catch (\\Throwable $e) {\n error_log('[helpers] siteTrustStats partners: ' . $e->getMessage());\n }\n\n $seen = [];\n $merged = [];\n foreach (array_merge($crm, $partners) as $row) {\n $key = strtolower(trim((string)($row['org_name'] ?? '')));\n if ($key === '' || isset($seen[$key])) continue;\n $seen[$key] = true;\n $merged[] = $row;\n }\n\n $unique = count($merged);\n $clientCount = $unique + $offset;\n\n $marquee = $merged;\n if (count($marquee) > 40) {\n shuffle($marquee);\n $marquee = array_slice($marquee, 0, 40);\n }\n\n $coopLabel = trim((string)($s['stat_coop_clients_label'] ?? ''));\n $partnersLabel = trim((string)($s['stat_partners_label'] ?? ''));\n $provincesLabel = trim((string)($s['stat_provinces_label'] ?? ''));\n // Guard: admins sometimes put numbers in Label fields by mistake\n if ($coopLabel === '' || preg_match('/^[\\d.,+\\s%]+$/u', $coopLabel)) {\n $coopLabel = 'Cooperative Clients';\n }\n if ($partnersLabel === '' || preg_match('/^[\\d.,+\\s%]+$/u', $partnersLabel)) {\n $partnersLabel = 'Technology Partners';\n }\n if ($provincesLabel === '' || preg_match('/^[\\d.,+\\s%]+$/u', $provincesLabel)) {\n $provincesLabel = 'Provinces Covered';\n }\n\n return [\n 'client_count' => $clientCount,\n 'client_display' => $clientCount . '+',\n 'coop_label' => $coopLabel,\n 'partners_value' => trim((string)($s['stat_partners_value'] ?? '')) ?: '15+',\n 'partners_label' => $partnersLabel,\n 'provinces_value' => trim((string)($s['stat_provinces_value'] ?? '')) ?: '7',\n 'provinces_label' => $provincesLabel,\n 'marquee_items' => $marquee,\n 'unique_orgs' => $unique,\n ];\n}\n\n/** True when a public stat label means client HEADCOUNT (not retention / years / etc.). */\nfunction siteTrustLabelIsClientCount(string $label): bool {\n $l = mb_strtolower(trim($label));\n if ($l === '') return false;\n // Never overwrite metrics that only mention \"client\" incidentally\n if (preg_match('/retention|rate|satisfaction|uptime|response|year|experience|product|ticket|sla|nps|churn|revenue/', $l)) {\n return false;\n }\n // Headcount-style labels only\n return (bool)preg_match(\n '/^(happy\\s+)?clients?(?:\\s+served)?$|^(cooperative\\s+)?clients?$|clients?\\s+served|cooperatives?\\s+(served|trust)|सहकारी|ग्राहक/',\n $l\n );\n}\n\n/**\n * CTA subtitle with the same client count as the homepage trust strip.\n */\nfunction siteTrustCtaSubtitle(?array $settings = null): string {\n $t = siteTrustStats($settings);\n if (function_exists('isNepali') && isNepali()) {\n return $t['client_display'] . ' सहकारीहरू पहिले नै हाम्रो डिजिटल प्लेटफर्ममा चलिरहेका छन्। विशेषज्ञसँग कुरा गर्नुस।';\n }\n return 'Join ' . $t['client_display'] . ' happy clients already running on our digital platform. Talk to our experts about a solution tailored to your organisation.';\n}\n\n/**\n * Clean one impact-stat pair so messy admin values don't look broken publicly.\n * @return array{0:string,1:string,2:string,3:string} value, label, icon, hint\n */\nfunction siteStatNormalizeItem(array $row, int $index = 0): array {\n $defaults = [\n ['10+', 'Years of Experience', 'calendar', 'Years in the field'],\n ['650+', 'Happy Clients', 'users', 'Organisations we support'],\n ['7+', 'Major Products', 'layers', 'Core platforms live'],\n ['99.9%', 'Client Retention Rate', 'shield-check', 'Long-term partnerships'],\n ];\n $def = $defaults[$index] ?? ['—', 'Stat', 'star', ''];\n\n $val = trim((string)($row[0] ?? ''));\n $lab = trim((string)($row[1] ?? ''));\n $icon = trim((string)($row[2] ?? ''));\n $hint = trim((string)($row[3] ?? ''));\n\n if ($lab === '' || preg_match('/^[\\d.,+\\s%<]+$/u', $lab)) {\n $lab = $def[1];\n }\n if ($val === '') $val = $def[0];\n\n if (preg_match('/^([\\d,.]+)([A-Za-z].+)$/u', $val, $m)) {\n $val = $m[1] . ' ' . $m[2];\n }\n\n $ll = mb_strtolower($lab);\n\n if (preg_match('/year|experience|वर्ष/', $ll)) {\n if (preg_match('/(\\d+)\\s*years?/i', $val, $m) || preg_match('/(\\d+)\\s*years?/i', $lab, $m)) {\n $val = $m[1] . '+';\n } elseif (!preg_match('/\\d/', $val)) {\n $val = $def[0];\n }\n if ($icon === '') $icon = 'calendar';\n if ($hint === '') $hint = 'Years in the field';\n }\n\n if (preg_match('/product|platform|solution/', $ll)) {\n if ($val === '' || preg_match('/^<\\s*[\\d.]/u', $val) || preg_match('/\\bhr\\b|hour|min|ticket|response/i', $val)) {\n $val = '7+';\n }\n if ($icon === '') $icon = 'layers';\n if ($hint === '') $hint = 'Core platforms live';\n }\n\n if (preg_match('/retention|uptime|sla/', $ll)) {\n if ($val === '' || !preg_match('/\\d/', $val)) $val = '99.9%';\n if ($icon === '') $icon = 'shield-check';\n if ($hint === '') $hint = 'Long-term partnerships';\n }\n\n if (siteTrustLabelIsClientCount($lab)) {\n if ($icon === '') $icon = 'users';\n if ($hint === '') $hint = 'Live client count';\n }\n\n if ($icon === '') $icon = $def[2];\n if ($hint === '') $hint = $def[3];\n\n return [$val, $lab, $icon, $hint];\n}\n\n// ── Form helper functions — always available (not DB-dependent) ──────────────\n\nfunction formInput(string $label, string $name, mixed $value = '', array $opts = []): string {\n $type = $opts['type'] ?? 'text';\n $placeholder = $opts['placeholder'] ?? '';\n $requiredFlag = !empty($opts['required']) || in_array('required', $opts, true);\n $required = $requiredFlag ? ' required' : '';\n $attrs = $opts['attrs'] ?? '';\n $class = $opts['class'] ?? 'form-input';\n $id = $opts['id'] ?? $name;\n $html = '<div class=\"form-group\">';\n $html .= '<label class=\"form-label\" for=\"' . e($id) . '\">' . e($label);\n if ($requiredFlag) $html .= ' <span class=\"text-danger-token\">*</span>';\n $html .= '</label>';\n $html .= '<input type=\"' . e($type) . '\" id=\"' . e($id) . '\" name=\"' . e($name) . '\"'\n . ' class=\"' . e($class) . '\" value=\"' . e((string)($value ?? '')) . '\"'\n . $required . ($placeholder ? ' placeholder=\"' . e($placeholder) . '\"' : '')\n . ($attrs ? ' ' . $attrs : '') . '>';\n if (!empty($opts['hint'])) $html .= '<span class=\"form-hint\">' . e($opts['hint']) . '</span>';\n $html .= '</div>';\n return $html;\n}\n\nfunction formTextarea(string $label, string $name, mixed $value = '', array $opts = []): string {\n $rows = (int)($opts['rows'] ?? 4);\n $placeholder = $opts['placeholder'] ?? '';\n $requiredFlag = !empty($opts['required']) || in_array('required', $opts, true);\n $required = $requiredFlag ? ' required' : '';\n $class = $opts['class'] ?? 'form-textarea';\n $id = $opts['id'] ?? $name;\n $html = '<div class=\"form-group\">';\n $html .= '<label class=\"form-label\" for=\"' . e($id) . '\">' . e($label);\n if ($requiredFlag) $html .= ' <span class=\"text-danger-token\">*</span>';\n $html .= '</label>';\n $html .= '<textarea id=\"' . e($id) . '\" name=\"' . e($name) . '\"'\n . ' class=\"' . e($class) . '\" rows=\"' . $rows . '\"'\n . $required . ($placeholder ? ' placeholder=\"' . e($placeholder) . '\"' : '') . '>'\n . e((string)($value ?? '')) . '</textarea>';\n if (!empty($opts['hint'])) $html .= '<span class=\"form-hint\">' . e($opts['hint']) . '</span>';\n $html .= '</div>';\n return $html;\n}\n\nfunction formSelect(string $label, string $name, mixed $value = '', array $options = [], array $opts = []): string {\n $requiredFlag = !empty($opts['required']) || in_array('required', $opts, true);\n $required = $requiredFlag ? ' required' : '';\n $class = $opts['class'] ?? 'form-select';\n $id = $opts['id'] ?? $name;\n $html = '<div class=\"form-group\">';\n $html .= '<label class=\"form-label\" for=\"' . e($id) . '\">' . e($label);\n if (in_array('required', $opts, true)) $html .= ' <span class=\"text-danger-token\">*</span>';\n $html .= '</label>';\n $html .= '<select id=\"' . e($id) . '\" name=\"' . e($name) . '\"'\n . ' class=\"' . e($class) . '\"' . $required . '>';\n if (empty($opts['no_blank'])) $html .= '<option value=\"\">— Select —</option>';\n foreach ($options as $optVal => $optLbl) {\n $sel = ((string)$value === (string)$optVal) ? ' selected' : '';\n $html .= '<option value=\"' . e((string)$optVal) . '\"' . $sel . '>' . e((string)$optLbl) . '</option>';\n }\n $html .= '</select>';\n if (!empty($opts['hint'])) $html .= '<span class=\"form-hint\">' . e($opts['hint']) . '</span>';\n $html .= '</div>';\n return $html;\n}\n\nfunction formCheckbox(string $label, string $name, bool $checked = false, array $opts = []): string {\n $val = $opts['value'] ?? '1';\n $id = $opts['id'] ?? $name;\n $html = '<label class=\"form-check\">';\n $html .= '<input type=\"checkbox\" id=\"' . e($id) . '\" name=\"' . e($name) . '\"'\n . ' value=\"' . e($val) . '\"' . ($checked ? ' checked' : '') . '>';\n $html .= '<span>' . e($label) . '</span></label>';\n return $html;\n}\n\nfunction formRow(string ...$items): string {\n $n = count($items);\n $class = $n === 3 ? 'form-grid-3' : 'form-grid-2';\n return '<div class=\"' . $class . '\">' . implode('', $items) . '</div>';\n}\n\nfunction formSection(string $title = '', string $content = ''): string {\n $html = '<div class=\"form-section\">';\n if ($title !== '') $html .= '<div class=\"form-section-title\">' . e($title) . '</div>';\n $html .= $content . '</div>';\n return $html;\n}\n\n// ── CMS bilingual helper ─────────────────────────────────────────\n// Returns Nepali site_settings value when user is browsing in Nepali,\n// falls back to English value, then to $default.\nfunction cms(array $s, string $key, string $default = ''): string {\n if (isNepali()) {\n $np = trim((string)($s[$key . '_np'] ?? ''));\n if ($np !== '') return $np;\n }\n $en = trim((string)($s[$key] ?? ''));\n return $en !== '' ? $en : $default;\n}\n\nfunction stSiteName(): string {\n $s = siteSettings();\n $n = trim((string)($s['site_name'] ?? ''));\n if ($n !== '') return $n;\n if (defined('SITE_NAME') && trim((string)SITE_NAME) !== '') return (string)SITE_NAME;\n return 'Company';\n}\n\nfunction stCompanyName(): string {\n $s = siteSettings();\n $n = trim((string)($s['company_name'] ?? ''));\n return $n !== '' ? $n : stSiteName();\n}\n\nfunction stContactEmail(): string {\n $s = siteSettings();\n return trim((string)($s['contact_email'] ?? ''));\n}\n\nfunction stContactPhone(): string {\n $s = siteSettings();\n return trim((string)($s['contact_phone'] ?? ''));\n}\n\nfunction stAddress(): string {\n $s = siteSettings();\n return trim((string)($s['address'] ?? ''));\n}\n\n/** Digits-only WhatsApp number (country code + number), or '' if disabled/empty. */\nfunction stWhatsAppNumber(): string {\n $s = siteSettings();\n $en = $s['whatsapp_enabled'] ?? '1';\n if ($en === false || $en === 0 || $en === '0') return '';\n return preg_replace('/\\D/', '', (string)($s['whatsapp_number'] ?? '')) ?: '';\n}\n\nfunction stWhatsAppLabel(): string {\n $s = siteSettings();\n $label = trim((string)($s['whatsapp_label'] ?? ''));\n return $label !== '' ? $label : 'Support WhatsApp';\n}\n\n/**\n * Personalized click-to-chat message.\n * Placeholders in Settings message: {name} {org} {email} {phone} {company} {page}\n */\nfunction stWhatsAppMessage(?array $user = null, ?string $pageHint = null): string {\n $s = siteSettings();\n $company = stCompanyName();\n $tpl = trim((string)($s['whatsapp_message'] ?? ''));\n if ($tpl === '') {\n $tpl = 'Hello {company} Support!';\n }\n\n $name = '';\n $org = '';\n $email = '';\n $phone = '';\n if ($user) {\n $name = trim((string)($user['display_name'] ?? $user['name'] ?? ''));\n $org = trim((string)($user['org_name'] ?? ''));\n $email = trim((string)($user['email'] ?? ''));\n $phone = trim((string)($user['phone'] ?? ''));\n } elseif (function_exists('currentUser')) {\n $u = currentUser();\n if ($u) {\n $name = trim((string)($u['display_name'] ?? $u['name'] ?? ''));\n $org = trim((string)($u['org_name'] ?? ''));\n $email = trim((string)($u['email'] ?? ''));\n $phone = trim((string)($u['phone'] ?? ''));\n }\n }\n\n $page = trim((string)($pageHint ?? ''));\n if ($page === '' && !empty($_SERVER['REQUEST_URI'])) {\n $page = basename(parse_url((string)$_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '');\n }\n\n $msg = strtr($tpl, [\n '{name}' => $name !== '' ? $name : 'Guest',\n '{org}' => $org !== '' ? $org : '—',\n '{email}' => $email !== '' ? $email : '—',\n '{phone}' => $phone !== '' ? $phone : '—',\n '{company}' => $company,\n '{page}' => $page !== '' ? $page : 'website',\n ]);\n\n // If template has no placeholders and user is logged in, append identity for support staff\n $hasPlaceholder = (bool)preg_match('/\\{(name|org|email|phone|company|page)\\}/', $tpl);\n if (!$hasPlaceholder && ($name !== '' || $org !== '' || $email !== '')) {\n $bits = array_filter([\n $name !== '' ? 'Name: ' . $name : '',\n $org !== '' ? 'Org: ' . $org : '',\n $email !== '' ? 'Email: ' . $email : '',\n $phone !== '' ? 'Phone: ' . $phone : '',\n ]);\n if ($bits) {\n $msg = rtrim($msg) . \"\\n\\n—\\n\" . implode(\"\\n\", $bits);\n }\n }\n\n return $msg;\n}\n\n/** Full wa.me URL for support chat (empty string if WhatsApp disabled / no number). */\nfunction stWhatsAppUrl(?array $user = null, ?string $pageHint = null): string {\n $num = stWhatsAppNumber();\n if ($num === '') return '';\n $text = stWhatsAppMessage($user, $pageHint);\n return 'https://wa.me/' . $num . '?text=' . rawurlencode($text);\n}\n\n/**\n * Org-chart rank for a team member (1 = top row / apex).\n * Manual org_tier (1–5) wins; otherwise inferred from role title.\n */\nfunction teamOrgRank(array $member): int {\n $manual = (int)($member['org_tier'] ?? 0);\n if ($manual >= 1 && $manual <= 5) {\n return $manual;\n }\n\n $role = function_exists('mb_strtolower')\n ? mb_strtolower(trim((string)($member['role'] ?? '')))\n : strtolower(trim((string)($member['role'] ?? '')));\n if ($role === '') {\n return 3;\n }\n\n // Apex — typically alone on the first row (Chairman before generic Director)\n if (preg_match('/chair\\s*man|chair\\s*person|\\bchair\\b|अध्यक्ष|\\bpresident\\b/u', $role)) {\n return 1;\n }\n if (preg_match('/\\bceo\\b|chief\\s+executive|managing\\s+director|\\bmd\\b|\\bfounder\\b|प्रमुख\\s*कार्यकारी/u', $role)) {\n return 1;\n }\n // Second tier\n if (preg_match('/deputy|vice[\\s-]?chair|co[\\s-]?ceo|\\bcfo\\b|\\bcto\\b|\\bcoo\\b|chief\\s+/u', $role)) {\n return 2;\n }\n if (preg_match('/\\bdirector\\b|\\bmanager\\b|head\\s+of|\\blead\\b|निर्देशक|प्रबन्धक/u', $role)) {\n return 2;\n }\n return 3;\n}\n\n/**\n * Group members into org-chart rows (tier => members), sorted by tier then position.\n *\n * @param list<array> $members\n * @return array<int, list<array>>\n */\nfunction teamOrgChartRows(array $members): array {\n $members = array_values($members);\n usort($members, static function ($a, $b) {\n $ra = teamOrgRank($a);\n $rb = teamOrgRank($b);\n if ($ra !== $rb) return $ra <=> $rb;\n $pa = (int)($a['position'] ?? 0);\n $pb = (int)($b['position'] ?? 0);\n if ($pa !== $pb) return $pa <=> $pb;\n return strcasecmp((string)($a['name'] ?? ''), (string)($b['name'] ?? ''));\n });\n\n $rows = [];\n foreach ($members as $m) {\n $rows[teamOrgRank($m)][] = $m;\n }\n ksort($rows, SORT_NUMERIC);\n return $rows;\n}\n\n/** Whether the public AI assistant float is enabled and has a usable key. */\nfunction stAiChatEnabled(): bool {\n $s = siteSettings();\n if (($s['ai_chat_enabled'] ?? '0') !== '1') return false;\n $key = trim((string)($s['ai_chat_api_key'] ?? ''));\n return $key !== '' && stAiChatProvider() !== '';\n}\n\nfunction stAiChatProvider(): string {\n $s = siteSettings();\n $p = strtolower(trim((string)($s['ai_chat_provider'] ?? 'openai')));\n return in_array($p, ['openai', 'gemini', 'deepseek'], true) ? $p : 'openai';\n}\n\nfunction stAiChatLabel(): string {\n $s = siteSettings();\n $label = trim((string)($s['ai_chat_label'] ?? ''));\n return $label !== '' ? $label : 'AI Assistant';\n}\n\nfunction stAiChatWelcome(): string {\n $s = siteSettings();\n $w = trim((string)($s['ai_chat_welcome'] ?? ''));\n if ($w !== '') return $w;\n return 'Hi! Ask me about our products, services, pricing, or how to contact us. I only know public website information.';\n}\n\n/** Strip HTML and truncate plain text for the public AI knowledge pack. */\nfunction stAiChatPlain(?string $text, int $max = 700): string {\n $t = html_entity_decode(strip_tags((string)$text), ENT_QUOTES | ENT_HTML5, 'UTF-8');\n $t = preg_replace('/\\s+/u', ' ', $t) ?? $t;\n $t = trim($t);\n if ($t === '') return '';\n $len = function_exists('mb_strlen') ? mb_strlen($t) : strlen($t);\n if ($len > $max) {\n $t = (function_exists('mb_substr') ? mb_substr($t, 0, max(1, $max - 1)) : substr($t, 0, max(1, $max - 1))) . '…';\n }\n return $t;\n}\n\n/** Decode JSON list fields (features/highlights) into clean strings. */\nfunction stAiChatJsonList(?string $json, int $limit = 20): array {\n $arr = json_decode((string)$json, true);\n if (!is_array($arr)) {\n $arr = array_filter(array_map('trim', explode(',', (string)$json)));\n }\n $out = [];\n foreach ($arr as $item) {\n if (is_array($item)) {\n $item = $item['name'] ?? $item['title'] ?? $item['label'] ?? $item['text'] ?? '';\n if (is_array($item)) $item = '';\n }\n $item = trim((string)$item);\n if ($item === '') continue;\n $out[] = $item;\n if (count($out) >= $limit) break;\n }\n return $out;\n}\n\n/**\n * Public-only knowledge pack for the AI assistant.\n * Whitelist: public marketing pages only.\n * Never includes API keys, admin users, CRM, tickets, applications, invoices, or secrets.\n */\nfunction stAiChatPublicContext(): string {\n $s = siteSettings();\n $lines = [];\n\n $lines[] = 'COMPANY (public):';\n $lines[] = 'Name: ' . stCompanyName();\n if (!empty($s['site_tagline'])) $lines[] = 'Tagline: ' . stAiChatPlain((string)$s['site_tagline'], 240);\n $website = trim((string)($s['company_website'] ?? ''));\n if ($website !== '') $lines[] = 'Website: ' . $website;\n if (stAddress() !== '') $lines[] = 'Address: ' . stAddress();\n if (stContactPhone() !== '') $lines[] = 'Phone: ' . stContactPhone();\n if (stContactEmail() !== '') $lines[] = 'Email: ' . stContactEmail();\n $support = trim((string)($s['support_email'] ?? ''));\n if ($support !== '') $lines[] = 'Support email: ' . $support;\n if (($s['whatsapp_enabled'] ?? '1') !== '0') {\n $wa = preg_replace('/\\D+/', '', (string)($s['whatsapp_number'] ?? '')) ?: '';\n if ($wa !== '') {\n $lines[] = 'WhatsApp support: +' . $wa\n . (!empty($s['whatsapp_label']) ? (' (' . stAiChatPlain((string)$s['whatsapp_label'], 60) . ')') : '');\n }\n }\n\n // About / mission / values (public About page)\n $aboutBits = [];\n if (!empty($s['about_mission_h2'])) $aboutBits[] = 'Heading: ' . stAiChatPlain((string)$s['about_mission_h2'], 120);\n if (!empty($s['about_mission_p1'])) $aboutBits[] = stAiChatPlain((string)$s['about_mission_p1'], 500);\n if (!empty($s['about_mission_p2'])) $aboutBits[] = stAiChatPlain((string)$s['about_mission_p2'], 500);\n if (!empty($s['about_vision_quote'])) $aboutBits[] = 'Vision: ' . stAiChatPlain((string)$s['about_vision_quote'], 300);\n for ($i = 1; $i <= 4; $i++) {\n $vt = trim((string)($s[\"about_val{$i}_title\"] ?? ''));\n $vd = trim((string)($s[\"about_val{$i}_desc\"] ?? ''));\n if ($vt === '' && $vd === '') continue;\n $aboutBits[] = 'Value — ' . stAiChatPlain($vt, 80) . ': ' . stAiChatPlain($vd, 220);\n }\n if ($aboutBits) {\n $lines[] = '';\n $lines[] = 'ABOUT (public):';\n foreach ($aboutBits as $b) $lines[] = $b;\n }\n\n // CEO message only when published on the public About page\n if (($s['ceo_active'] ?? '') === '1' || ($s['ceo_active'] ?? '') === 'true') {\n $ceoName = stAiChatPlain((string)($s['ceo_name'] ?? ''), 80);\n if ($ceoName !== '') {\n $lines[] = '';\n $lines[] = 'LEADERSHIP (public About page):';\n $lines[] = 'CEO / leader: ' . $ceoName\n . (!empty($s['ceo_title']) ? (' — ' . stAiChatPlain((string)$s['ceo_title'], 80)) : '');\n if (!empty($s['ceo_message'])) {\n $lines[] = 'Public message: ' . stAiChatPlain((string)$s['ceo_message'], 600);\n }\n }\n }\n\n // Public trust / impact stats (counts only — no CRM org dump)\n if (function_exists('siteTrustStats')) {\n try {\n $stats = siteTrustStats();\n if (is_array($stats)) {\n $lines[] = '';\n $lines[] = 'PUBLIC IMPACT STATS:';\n if (!empty($stats['client_display'])) {\n $lines[] = '- ' . $stats['client_display'] . ' ' . ($stats['coop_label'] ?? 'Clients');\n }\n if (!empty($stats['partners_value'])) {\n $lines[] = '- ' . $stats['partners_value'] . ' ' . ($stats['partners_label'] ?? 'Partners');\n }\n if (!empty($stats['provinces_value'])) {\n $lines[] = '- ' . $stats['provinces_value'] . ' ' . ($stats['provinces_label'] ?? 'Provinces');\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n }\n\n try {\n $products = query(\n \"SELECT name, slug, tagline, summary, description, badge, category, price_from, features, highlights\n FROM products WHERE active=1 ORDER BY position, id LIMIT 24\"\n );\n if ($products) {\n $lines[] = '';\n $lines[] = 'PRODUCTS (public catalog — detail pages exist for each slug):';\n foreach ($products as $p) {\n $price = (!empty($p['price_from']) && (float)$p['price_from'] > 0)\n ? ('NPR ' . number_format((float)$p['price_from'], 0) . '+')\n : 'Contact for price';\n $feats = stAiChatJsonList($p['features'] ?? '', 16);\n $highs = stAiChatJsonList($p['highlights'] ?? '', 12);\n $bits = array_values(array_unique(array_merge($highs, $feats)));\n $bits = array_slice($bits, 0, 16);\n $lines[] = '- ' . ($p['name'] ?? '')\n . (!empty($p['badge']) ? ' [' . $p['badge'] . ']' : '')\n . (!empty($p['category']) ? ' | Category: ' . $p['category'] : '')\n . (!empty($p['slug']) ? ' | Page: product-detail.php?slug=' . $p['slug'] : '');\n if (!empty($p['tagline'])) $lines[] = ' Tagline: ' . stAiChatPlain((string)$p['tagline'], 200);\n $sum = stAiChatPlain((string)($p['summary'] ?? ''), 400);\n if ($sum !== '') $lines[] = ' Summary: ' . $sum;\n $desc = stAiChatPlain((string)($p['description'] ?? ''), 900);\n if ($desc !== '' && $desc !== $sum) $lines[] = ' Details: ' . $desc;\n $lines[] = ' Pricing from: ' . $price;\n if ($bits) $lines[] = ' Features: ' . implode('; ', $bits);\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n // Public optional add-ons (Products page)\n if (function_exists('resolveProductsAddons')) {\n try {\n $addons = resolveProductsAddons($s);\n if (is_array($addons) && $addons) {\n $lines[] = '';\n $lines[] = 'OPTIONAL ADD-ONS (public Products page):';\n foreach (array_slice($addons, 0, 20) as $ad) {\n if (!is_array($ad)) continue;\n $n = stAiChatPlain((string)($ad['title'] ?? $ad['name'] ?? ''), 100);\n if ($n === '') continue;\n $pr = stAiChatPlain((string)($ad['price'] ?? ''), 60);\n $ds = stAiChatPlain((string)($ad['desc'] ?? ''), 220);\n $lines[] = '- ' . $n . ($pr !== '' ? (' — ' . $pr) : '') . ($ds !== '' ? (': ' . $ds) : '');\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n }\n\n try {\n $services = query(\n \"SELECT title AS name, slug, tagline, summary, description, badge, price_from, features, highlights\n FROM services WHERE active=1 ORDER BY position, id LIMIT 24\"\n );\n if ($services) {\n $lines[] = '';\n $lines[] = 'SERVICES (public catalog):';\n foreach ($services as $sv) {\n $price = (!empty($sv['price_from']) && (float)$sv['price_from'] > 0)\n ? ('NPR ' . number_format((float)$sv['price_from'], 0) . '+')\n : 'Contact for quote';\n $feats = stAiChatJsonList($sv['features'] ?? '', 16);\n $highs = stAiChatJsonList($sv['highlights'] ?? '', 12);\n $bits = array_slice(array_values(array_unique(array_merge($highs, $feats))), 0, 16);\n $lines[] = '- ' . ($sv['name'] ?? '')\n . (!empty($sv['badge']) ? ' [' . $sv['badge'] . ']' : '')\n . (!empty($sv['slug']) ? ' | Page: service-detail.php?slug=' . $sv['slug'] : '');\n if (!empty($sv['tagline'])) $lines[] = ' Tagline: ' . stAiChatPlain((string)$sv['tagline'], 200);\n $sum = stAiChatPlain((string)($sv['summary'] ?? ''), 400);\n if ($sum !== '') $lines[] = ' Summary: ' . $sum;\n $desc = stAiChatPlain((string)($sv['description'] ?? ''), 900);\n if ($desc !== '' && $desc !== $sum) $lines[] = ' Details: ' . $desc;\n $lines[] = ' Pricing from: ' . $price;\n if ($bits) $lines[] = ' Features: ' . implode('; ', $bits);\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n try {\n $plans = query(\n \"SELECT name, tag, price_label, period, features FROM pricing_plans WHERE active=1 ORDER BY position, id LIMIT 16\"\n );\n if ($plans) {\n $lines[] = '';\n $lines[] = 'PRICING PLANS (public):';\n foreach ($plans as $pl) {\n $feats = stAiChatJsonList($pl['features'] ?? '', 14);\n $lines[] = '- ' . ($pl['name'] ?? '')\n . (!empty($pl['tag']) ? ' (' . $pl['tag'] . ')' : '')\n . ': ' . trim(($pl['price_label'] ?? '') . ' ' . ($pl['period'] ?? ''))\n . ($feats ? (' | Includes: ' . implode('; ', $feats)) : '');\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n try {\n $partners = query(\n \"SELECT name, type, district FROM partners WHERE active=1 ORDER BY position ASC, id ASC LIMIT 60\"\n );\n if ($partners) {\n $lines[] = '';\n $lines[] = 'PARTNERS / CLIENTS (public Partners page — names only):';\n foreach ($partners as $pt) {\n $lines[] = '- ' . ($pt['name'] ?? '')\n . (!empty($pt['type']) ? ' (' . $pt['type'] . ')' : '')\n . (!empty($pt['district']) ? ' — ' . $pt['district'] : '');\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n try {\n $portfolio = query(\n \"SELECT title, client_name, category, excerpt, description, result_metric\n FROM portfolio WHERE active=1 ORDER BY position ASC, id DESC LIMIT 16\"\n );\n if ($portfolio) {\n $lines[] = '';\n $lines[] = 'PORTFOLIO / CASE STUDIES (public):';\n foreach ($portfolio as $pf) {\n $lines[] = '- ' . ($pf['title'] ?? '')\n . (!empty($pf['client_name']) ? ' | Client: ' . $pf['client_name'] : '')\n . (!empty($pf['category']) ? ' | ' . $pf['category'] : '');\n $ex = stAiChatPlain((string)(($pf['excerpt'] ?? '') ?: ($pf['description'] ?? '')), 350);\n if ($ex !== '') $lines[] = ' ' . $ex;\n if (!empty($pf['result_metric'])) {\n $lines[] = ' Result: ' . stAiChatPlain((string)$pf['result_metric'], 120);\n }\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n try {\n $team = query(\n \"SELECT name, role, bio, is_leadership FROM team_members WHERE active=1\n ORDER BY is_leadership DESC, position LIMIT 20\"\n );\n if ($team) {\n $lines[] = '';\n $lines[] = 'TEAM (public About page — no private emails):';\n foreach ($team as $tm) {\n $lines[] = '- ' . ($tm['name'] ?? '')\n . (!empty($tm['role']) ? ' — ' . $tm['role'] : '')\n . (!empty($tm['is_leadership']) ? ' [leadership]' : '');\n $bio = stAiChatPlain((string)($tm['bio'] ?? ''), 220);\n if ($bio !== '') $lines[] = ' ' . $bio;\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n try {\n $news = query(\n \"SELECT title, slug, excerpt, category, published_at FROM news\n WHERE published=1 AND active=1 ORDER BY published_at DESC LIMIT 10\"\n );\n if ($news) {\n $lines[] = '';\n $lines[] = 'NEWS / UPDATES (public):';\n foreach ($news as $n) {\n $lines[] = '- ' . ($n['title'] ?? '')\n . (!empty($n['category']) ? ' [' . $n['category'] . ']' : '')\n . (!empty($n['published_at']) ? ' (' . substr((string)$n['published_at'], 0, 10) . ')' : '');\n $ex = stAiChatPlain((string)($n['excerpt'] ?? ''), 280);\n if ($ex !== '') $lines[] = ' ' . $ex;\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n try {\n $jobs = query(\n \"SELECT title, department, location, type, experience, salary_range, short_desc, deadline\n FROM job_listings WHERE active=1 ORDER BY position, id DESC LIMIT 12\"\n );\n if ($jobs) {\n $open = [];\n foreach ($jobs as $j) {\n if (!empty($j['deadline']) && strtotime((string)$j['deadline']) < strtotime('today')) {\n continue; // expired — not shown publicly\n }\n $open[] = $j;\n }\n if ($open) {\n $lines[] = '';\n $lines[] = 'OPEN CAREERS (public Careers page — not applications):';\n foreach ($open as $j) {\n $lines[] = '- ' . ($j['title'] ?? '')\n . (!empty($j['department']) ? ' | ' . $j['department'] : '')\n . (!empty($j['location']) ? ' | ' . $j['location'] : '')\n . (!empty($j['type']) ? ' | ' . $j['type'] : '');\n if (!empty($j['experience'])) $lines[] = ' Experience: ' . stAiChatPlain((string)$j['experience'], 120);\n if (!empty($j['salary_range'])) $lines[] = ' Salary: ' . stAiChatPlain((string)$j['salary_range'], 80);\n $sd = stAiChatPlain((string)($j['short_desc'] ?? ''), 280);\n if ($sd !== '') $lines[] = ' ' . $sd;\n if (!empty($j['deadline'])) $lines[] = ' Apply by: ' . $j['deadline'];\n }\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n try {\n $faqs = query(\n \"SELECT category, question, answer FROM faqs WHERE active=1 ORDER BY position, id LIMIT 25\"\n );\n if ($faqs) {\n $lines[] = '';\n $lines[] = 'FAQs (public):';\n foreach ($faqs as $f) {\n $lines[] = 'Q' . (!empty($f['category']) ? ' [' . $f['category'] . ']' : '') . ': '\n . stAiChatPlain((string)$f['question'], 240);\n $lines[] = 'A: ' . stAiChatPlain((string)($f['answer'] ?? ''), 700);\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n try {\n $testimonials = query(\n \"SELECT author_name, author_role, author_org, quote FROM testimonials\n WHERE active=1 ORDER BY position, id DESC LIMIT 8\"\n );\n if ($testimonials) {\n $lines[] = '';\n $lines[] = 'TESTIMONIALS (public):';\n foreach ($testimonials as $t) {\n $who = trim(($t['author_name'] ?? '')\n . (!empty($t['author_role']) ? ', ' . $t['author_role'] : '')\n . (!empty($t['author_org']) ? ' @ ' . $t['author_org'] : ''));\n $q = stAiChatPlain((string)($t['quote'] ?? ''), 280);\n if ($q === '') continue;\n $lines[] = '- \"' . $q . '\"' . ($who !== '' ? (' — ' . $who) : '');\n }\n }\n } catch (\\Throwable $e) { /* optional */ }\n\n $lines[] = '';\n $lines[] = 'PUBLIC PAGES: Home, About, Products, Product details, Services, Service details, Pricing, Portfolio, Partners, News, Careers, FAQ, Contact, Tools.';\n $lines[] = 'How to buy / demo: use Contact form, Request a demo on product pages, or WhatsApp if listed above.';\n $lines[] = 'SECURITY BOUNDARY: You do NOT have admin panel, CRM, invoices, tickets, job applications, customer databases, API keys, or staff passwords. Never invent them.';\n\n $pack = implode(\"\\n\", $lines);\n // Soft cap so prompts stay within provider limits\n $maxPack = 28000;\n $plen = function_exists('mb_strlen') ? mb_strlen($pack) : strlen($pack);\n if ($plen > $maxPack) {\n $pack = (function_exists('mb_substr') ? mb_substr($pack, 0, $maxPack) : substr($pack, 0, $maxPack))\n . \"\\n…[public context truncated]\";\n }\n return $pack;\n}\n\nfunction stAiChatSystemPrompt(): string {\n $s = siteSettings();\n $extra = trim((string)($s['ai_chat_system_extra'] ?? ''));\n // Never allow owner notes to inject secrets or override security\n $extra = stAiChatPlain($extra, 1200);\n $company = stCompanyName();\n $prompt = \"You are the public website assistant for {$company}.\\n\"\n . \"Answer visitors using ONLY the PUBLIC WEBSITE INFORMATION below (public pages / published catalog).\\n\"\n . \"Be helpful and informative: include relevant product/service details, pricing when listed, features, contact options, and next steps.\\n\"\n . \"Match the visitor language (English or Nepali). Prefer clear structured answers (short paragraphs or bullets).\\n\"\n . \"\\nHARD SECURITY RULES (never break):\\n\"\n . \"1. NEVER reveal, invent, discuss, or hint at: admin panel/URLs, staff logins, passwords, API keys, database dumps, CRM leads, invoices, payments, job applications/CVs, internal tickets, private customer data, or anything behind /admin or portal login.\\n\"\n . \"2. If asked for admin access, keys, internal systems, customer lists, or private data: refuse politely and suggest the public Contact page or WhatsApp.\\n\"\n . \"3. If something is not in the public information pack, say you do not have that on the public site and offer Contact / Request a demo — do not guess private facts.\\n\"\n . \"4. Do not claim you can open admin, change settings, or access private files.\\n\"\n . \"5. Public leadership/CEO info may be shared ONLY if present in the public pack below.\\n\";\n if ($extra !== '') {\n $prompt .= \"\\nAdditional public guidance from the site owner (still subject to security rules):\\n\" . $extra . \"\\n\";\n }\n $prompt .= \"\\n--- PUBLIC WEBSITE INFORMATION ---\\n\" . stAiChatPublicContext();\n return $prompt;\n}\n\n/**\n * Prefer the admin-selected provider, but correct obvious key mismatches\n * (Gemini keys are AIza…; OpenAI/DeepSeek keys are typically sk-…).\n */\nfunction stAiChatResolveProvider(string $apiKey): string {\n $provider = stAiChatProvider();\n $key = trim($apiKey);\n if ($key !== '' && str_starts_with($key, 'AIza')) {\n return 'gemini';\n }\n if ($provider === 'gemini' && $key !== '' && str_starts_with($key, 'sk-')) {\n // Likely OpenAI/DeepSeek key saved while Gemini was selected\n return 'openai';\n }\n return $provider;\n}\n\nfunction stAiChatComplete(array $history): string {\n if (!stAiChatEnabled()) {\n throw new \\RuntimeException('AI chat is not configured.');\n }\n $s = siteSettings();\n $apiKey = trim((string)($s['ai_chat_api_key'] ?? ''));\n $provider = stAiChatResolveProvider($apiKey);\n $system = stAiChatSystemPrompt();\n\n $clean = [];\n foreach (array_slice($history, -10) as $m) {\n if (!is_array($m)) continue;\n $role = ($m['role'] ?? '') === 'assistant' ? 'assistant' : 'user';\n $content = trim((string)($m['content'] ?? ''));\n if ($content === '') continue;\n $len = function_exists('mb_strlen') ? mb_strlen($content) : strlen($content);\n if ($len > 1500) {\n $content = function_exists('mb_substr') ? mb_substr($content, 0, 1500) : substr($content, 0, 1500);\n }\n $clean[] = ['role' => $role, 'content' => $content];\n }\n if (empty($clean)) {\n throw new \\RuntimeException('Message required.');\n }\n\n if ($provider === 'gemini') {\n return stAiChatCallGemini($apiKey, $system, $clean);\n }\n $base = $provider === 'deepseek'\n ? 'https://api.deepseek.com/v1/chat/completions'\n : 'https://api.openai.com/v1/chat/completions';\n $model = $provider === 'deepseek' ? 'deepseek-chat' : 'gpt-4o-mini';\n return stAiChatCallOpenAiCompat($base, $apiKey, $model, $system, $clean);\n}\n\nfunction stAiChatHttpJson(string $url, array $headers, array $body, int $timeout = 45): array {\n if (!function_exists('curl_init')) {\n throw new \\RuntimeException('cURL is required for AI chat.');\n }\n $ch = curl_init($url);\n curl_setopt_array($ch, [\n CURLOPT_POST => true,\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_TIMEOUT => $timeout,\n CURLOPT_CONNECTTIMEOUT => 10,\n CURLOPT_HTTPHEADER => $headers,\n CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),\n ]);\n $raw = curl_exec($ch);\n $err = curl_error($ch);\n $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);\n curl_close($ch);\n if ($raw === false) {\n throw new \\RuntimeException('AI request failed: ' . ($err ?: 'network error'));\n }\n $data = json_decode($raw, true);\n if (!is_array($data)) {\n throw new \\RuntimeException('Invalid AI response.');\n }\n if ($code >= 400) {\n $msg = $data['error']['message'] ?? $data['error'] ?? ('HTTP ' . $code);\n if (is_array($msg)) $msg = json_encode($msg);\n $snip = function_exists('mb_substr') ? mb_substr((string)$msg, 0, 200) : substr((string)$msg, 0, 200);\n throw new \\RuntimeException('AI provider error: ' . $snip);\n }\n return $data;\n}\n\nfunction stAiChatCallOpenAiCompat(string $url, string $apiKey, string $model, string $system, array $history): string {\n $messages = array_merge(\n [['role' => 'system', 'content' => $system]],\n $history\n );\n $data = stAiChatHttpJson($url, [\n 'Content-Type: application/json',\n 'Authorization: Bearer ' . $apiKey,\n ], [\n 'model' => $model,\n 'messages' => $messages,\n 'temperature' => 0.4,\n 'max_tokens' => 1200,\n ]);\n $text = trim((string)($data['choices'][0]['message']['content'] ?? ''));\n if ($text === '') throw new \\RuntimeException('Empty AI reply.');\n return $text;\n}\n\n/**\n * Call Gemini with current Flash models. gemini-2.0-flash was shut down 2026-06-01.\n *\n * @param list<array{role:string,content:string}> $history\n */\nfunction stAiChatCallGemini(string $apiKey, string $system, array $history): string {\n // Prefer stable Flash → cheaper Flash-Lite fallbacks if a model ID is unavailable\n $models = ['gemini-3.5-flash', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'];\n $last = null;\n foreach ($models as $model) {\n try {\n return stAiChatCallGeminiModel($apiKey, $system, $history, $model);\n } catch (\\RuntimeException $e) {\n $last = $e;\n $msg = strtolower($e->getMessage());\n // Retry next model only when this model is missing / unavailable\n if (\n !str_contains($msg, 'not found')\n && !str_contains($msg, 'not supported')\n && !str_contains($msg, 'is not found')\n && !str_contains($msg, '404')\n ) {\n throw $e;\n }\n }\n }\n throw $last ?: new \\RuntimeException('Gemini request failed.');\n}\n\n/**\n * @param list<array{role:string,content:string}> $history\n */\nfunction stAiChatCallGeminiModel(string $apiKey, string $system, array $history, string $model): string {\n $contents = [];\n foreach ($history as $m) {\n $contents[] = [\n 'role' => $m['role'] === 'assistant' ? 'model' : 'user',\n 'parts' => [['text' => $m['content']]],\n ];\n }\n $url = 'https://generativelanguage.googleapis.com/v1beta/models/'\n . rawurlencode($model)\n . ':generateContent?key=' . rawurlencode($apiKey);\n $data = stAiChatHttpJson($url, [\n 'Content-Type: application/json',\n ], [\n 'systemInstruction' => ['parts' => [['text' => $system]]],\n 'contents' => $contents,\n 'generationConfig' => [\n 'temperature' => 0.4,\n 'maxOutputTokens' => 1200,\n ],\n ]);\n\n if (!empty($data['promptFeedback']['blockReason'])) {\n throw new \\RuntimeException('AI blocked the request (' . $data['promptFeedback']['blockReason'] . ').');\n }\n\n $parts = $data['candidates'][0]['content']['parts'] ?? [];\n $chunks = [];\n if (is_array($parts)) {\n foreach ($parts as $part) {\n if (!is_array($part)) continue;\n // Skip internal \"thought\" parts from thinking models\n if (!empty($part['thought'])) continue;\n $t = trim((string)($part['text'] ?? ''));\n if ($t !== '') $chunks[] = $t;\n }\n }\n $text = trim(implode(\"\\n\", $chunks));\n if ($text === '') {\n $finish = (string)($data['candidates'][0]['finishReason'] ?? '');\n throw new \\RuntimeException('Empty AI reply' . ($finish !== '' ? ' (' . $finish . ')' : '') . '.');\n }\n return $text;\n}\n\n// ── CSRF helpers ────────────────────────────────────────────────\nfunction generateCsrf(): string {\n if (session_status() === PHP_SESSION_NONE) session_start();\n if (empty($_SESSION['csrf_token'])) {\n $_SESSION['csrf_token'] = bin2hex(random_bytes(32));\n }\n return $_SESSION['csrf_token'];\n}\n\n// नेपालीमा: POST ko CSRF token check garne\nfunction verifyCsrf(?string $token = null): bool {\n if (session_status() === PHP_SESSION_NONE) session_start();\n $token = $token ?? ($_POST['_csrf'] ?? $_POST['_token'] ?? $_POST['csrf_token'] ?? '');\n $valid = !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);\n // Rotate instead of burn — avoids back-button CSRF errors\n if ($valid) {\n $_SESSION['csrf_token'] = bin2hex(random_bytes(32));\n } else {\n http_response_code(403);\n echo '<div class=\"alert alert-error\">Security token mismatch. Please <a href=\"javascript:history.back()\" style=\"text-decoration:underline;\">go back</a> and try again.</div>';\n exit;\n }\n return true;\n}\n\n// Legacy alias used in old pages\nfunction csrfToken(): string { return generateCsrf(); }\n\n// ── Badge helpers (uses theme.css badge classes) ─────────────────\nfunction statusBadge(string $status): string {\n $cls = 'badge-' . ($status ?: 'closed');\n return '<span class=\"badge ' . $cls . '\">' . e(ucwords(str_replace('_', ' ', $status))) . '</span>';\n}\n\n// नेपालीमा: Priority ko colored badge HTML banaune\nfunction priorityBadge(string $p): string {\n $cls = 'badge-' . ($p ?: 'normal');\n return '<span class=\"badge ' . $cls . '\">' . e(ucfirst($p)) . '</span>';\n}\n\n// ── Time ─────────────────────────────────────────────────────────\nfunction timeAgo(string $dt): string {\n $diff = time() - strtotime($dt);\n if ($diff < 60) return 'just now';\n if ($diff < 3600) return floor($diff / 60) . 'm ago';\n if ($diff < 86400) return floor($diff / 3600) . 'h ago';\n if ($diff < 604800) return floor($diff / 86400) . 'd ago';\n return date('M j, Y', strtotime($dt));\n}\n\n// ── Pagination ───────────────────────────────────────────────────\nfunction paginate(int $total, int $perPage, int $current): array {\n $pages = (int) ceil($total / $perPage);\n return [\n 'total' => $total,\n 'pages' => $pages,\n 'current' => $current,\n 'offset' => ($current - 1) * $perPage,\n 'perPage' => $perPage,\n ];\n}\n\n// ── Job application helpers ───────────────────────────────────────\nfunction applicantName(array $row): string {\n $name = trim($row['name'] ?? $row['full_name'] ?? '');\n return $name !== '' ? $name : '—';\n}\n\nfunction parseJobRequirements(?string $raw): array {\n if ($raw === null || trim($raw) === '') return [];\n $decoded = json_decode($raw, true);\n if (is_array($decoded) && !empty($decoded)) return $decoded;\n $lines = preg_split('/\\r\\n|\\r|\\n/', $raw);\n $reqs = [];\n foreach ($lines as $line) {\n $line = ltrim(trim($line), \"-•* \\t\");\n if ($line !== '') $reqs[] = $line;\n }\n return $reqs;\n}\n\nfunction isJobListingExpired(array $job): bool {\n if (empty($job['deadline'])) return false;\n return strtotime($job['deadline'] . ' 23:59:59') < time();\n}\n\n/** Public URL for a job listing (shareable, with ?job=slug deep link). */\nfunction jobListingPublicUrl(array $job): string {\n $slug = trim((string)($job['slug'] ?? ''));\n if ($slug !== '') {\n return url('careers.php?job=' . rawurlencode($slug));\n }\n return url('careers.php#job-' . (int)($job['id'] ?? 0));\n}\n\n/** Pre-formatted share text for WhatsApp / copy. */\nfunction jobListingShareMessage(array $job): string {\n $company = function_exists('stSiteName') ? stSiteName() : (defined('SITE_NAME') ? SITE_NAME : 'Company');\n $lines = ['We\\'re hiring: ' . ($job['title'] ?? 'Open position') . ' at ' . $company];\n if (!empty($job['location'])) $lines[] = '📍 ' . $job['location'];\n if (!empty($job['department'])) $lines[] = $job['department'];\n if (!empty($job['short_desc'])) $lines[] = $job['short_desc'];\n $lines[] = 'Apply: ' . jobListingPublicUrl($job);\n return implode(\"\\n\", $lines);\n}\n\n/** Social platform share URLs for a page or vacancy. */\nfunction socialShareLinks(string $url, string $title, ?string $message = null): array {\n $text = $message ?? ($title . ' — ' . $url);\n return [\n 'whatsapp' => 'https://wa.me/?text=' . rawurlencode($text),\n 'facebook' => 'https://www.facebook.com/sharer/sharer.php?u=' . rawurlencode($url),\n 'linkedin' => 'https://www.linkedin.com/sharing/share-offsite/?url=' . rawurlencode($url),\n 'twitter' => 'https://twitter.com/intent/tweet?text=' . rawurlencode($title) . '&url=' . rawurlencode($url),\n 'telegram' => 'https://t.me/share/url?url=' . rawurlencode($url) . '&text=' . rawurlencode($title),\n ];\n}\n\n/** Count currently open job listings (cached per request). */\nfunction navOpenJobCount(): int {\n static $cached = null;\n if ($cached !== null) return $cached;\n $cached = 0;\n $today = date('Y-m-d');\n try {\n $row = queryOne(\n \"SELECT COUNT(*) AS c FROM job_listings\n WHERE active=1\n AND (deadline IS NULL OR deadline='' OR deadline >= ?)\n AND (starts_at IS NULL OR starts_at='' OR starts_at <= ?)\",\n [$today, $today]\n );\n $cached = (int)($row['c'] ?? 0);\n } catch (\\Throwable $e) {\n try {\n $row = queryOne(\"SELECT COUNT(*) AS c FROM job_listings WHERE active=1\");\n $cached = (int)($row['c'] ?? 0);\n } catch (\\Throwable $e2) {\n $cached = 0;\n }\n }\n return $cached;\n}\n\n/** Count news posts published in the last N days (cached). */\nfunction navRecentNewsCount(int $days = 14): int {\n static $cache = [];\n $days = max(1, $days);\n if (isset($cache[$days])) return $cache[$days];\n $cache[$days] = 0;\n $since = date('Y-m-d H:i:s', time() - ($days * 86400));\n try {\n $row = queryOne(\n \"SELECT COUNT(*) AS c FROM news\n WHERE active=1 AND published=1\n AND COALESCE(published_at, created_at) >= ?\",\n [$since]\n );\n $cache[$days] = (int)($row['c'] ?? 0);\n } catch (\\Throwable $e) {\n try {\n $row = queryOne(\n \"SELECT COUNT(*) AS c FROM news WHERE published=1 AND published_at >= ?\",\n [$since]\n );\n $cache[$days] = (int)($row['c'] ?? 0);\n } catch (\\Throwable $e2) {\n $cache[$days] = 0;\n }\n }\n return $cache[$days];\n}\n\n/**\n * Badge text for a nav link, or null if it should not show.\n *\n * Link keys:\n * - badge: static label (e.g. NEW)\n * - badge_until: YYYY-MM-DD — hide after this date\n * - badge_if: open_jobs | recent_news — require live content\n * - badge_mode: count | label — for open_jobs/recent_news, show number or static badge\n * - badge_days: for recent_news window (default 14)\n */\nfunction navBadgeText(array $link): ?string {\n $until = $link['badge_until'] ?? null;\n if (!empty($until)) {\n $ts = strtotime((string)$until . ' 23:59:59');\n if ($ts === false || $ts < time()) return null;\n }\n\n $cond = $link['badge_if'] ?? null;\n $mode = $link['badge_mode'] ?? 'label';\n $label = trim((string)($link['badge'] ?? 'NEW'));\n\n if ($cond === 'open_jobs') {\n $n = navOpenJobCount();\n if ($n < 1) return null;\n return $mode === 'count' ? (string)$n : ($label !== '' ? $label : 'NEW');\n }\n\n if ($cond === 'recent_news') {\n $n = navRecentNewsCount((int)($link['badge_days'] ?? 14));\n if ($n < 1) return null;\n return $mode === 'count' ? (string)$n : ($label !== '' ? $label : 'NEW');\n }\n\n // Static badge (optional expiry already checked)\n if ($label === '' || empty($link['badge'])) return null;\n return $label;\n}\n\n/** Whether a nav link should show its badge. */\nfunction navShowBadge(array $link): bool {\n return navBadgeText($link) !== null;\n}\n\n/** True if any link in a nav group has an active badge. */\nfunction navHasChildBadge(array $links): bool {\n foreach ($links as $l) {\n if (navShowBadge($l)) return true;\n }\n return false;\n}\n\n/** Human-readable employment type for job cards. */\nfunction jobListingTypeLabel(?string $type): string {\n $map = [\n 'full-time' => 'Full-time',\n 'part-time' => 'Part-time',\n 'contract' => 'Contract',\n 'internship' => 'Internship',\n ];\n $key = strtolower(trim((string)$type));\n return $map[$key] ?? ucfirst(str_replace('_', ' ', $key ?: 'Role'));\n}\n\n/** Days remaining until job deadline (null if open-ended). */\nfunction jobListingDaysLeft(array $job): ?int {\n if (empty($job['deadline'])) return null;\n return (int)ceil((strtotime($job['deadline'] . ' 23:59:59') - time()) / 86400);\n}\n\n/**\n * Normalize YYYY-MM-DD from admin forms (BS picker hidden AD field).\n * Rejects empty/zero dates and mistaken BS-epoch AD values (1943-04-14).\n */\nfunction normalizeJobListingDate(?string $value): ?string {\n $value = trim((string)$value);\n if ($value === '' || $value === '0000-00-00' || str_starts_with($value, '0000-')) {\n return null;\n }\n $date = substr($value, 0, 10);\n if (!preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $date)) {\n return null;\n }\n $ts = strtotime($date);\n if ($ts === false || $ts < strtotime('1970-01-01')) {\n return null;\n }\n return $date;\n}\n\n/** True if a job listing date is valid for public visibility checks. */\nfunction jobListingDateIsFutureOrToday(?string $date): bool {\n if (!$date) return true;\n $ts = strtotime($date);\n return $ts !== false && $ts >= strtotime('today');\n}\n\n// ── Upload helper ─────────────────────────────────────────────────\nfunction handleUpload(string $field, string $dir = 'uploads'): ?string {\n if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) return null;\n $file = $_FILES[$field];\n $maxBytes = 5 * 1024 * 1024;\n if ($file['size'] > $maxBytes) return null;\n $finfo = finfo_open(FILEINFO_MIME_TYPE);\n $realMime = finfo_file($finfo, $file['tmp_name']);\n finfo_close($finfo);\n $allowed = ['image/jpeg','image/png','image/webp','image/gif','application/pdf'];\n if (!in_array($realMime, $allowed, true)) return null;\n $mimeToExt = ['image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','application/pdf'=>'pdf'];\n $ext = $mimeToExt[$realMime] ?? strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));\n $name = bin2hex(random_bytes(12)) . '.' . $ext;\n $dest = __DIR__ . '/../' . $dir . '/' . $name;\n if (!is_dir(dirname($dest))) mkdir(dirname($dest), 0755, true);\n if (!move_uploaded_file($file['tmp_name'], $dest)) return null;\n return SITE_URL . '/' . $dir . '/' . $name;\n}\n\n// ── Text truncate ─────────────────────────────────────────────────\nfunction truncate(string $s, int $len = 100, string $suffix = '…'): string {\n return mb_strlen($s) <= $len ? $s : mb_substr($s, 0, $len) . $suffix;\n}\n\n// ── Slug generator ────────────────────────────────────────────────\nfunction makeSlug(string $s): string {\n $s = strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $s), '-'));\n return preg_replace('/-+/', '-', $s);\n}\n\n// नेपालीमा: csrfField() — yo function le aafno kaam garchha\nfunction csrfField(): string {\n return '<input type=\"hidden\" name=\"_csrf\" value=\"' . e(generateCsrf()) . '\">';\n}\n\n// नेपालीमा: Lucide SVG icon ko HTML banaune (size + inline style sahit)\nfunction icon(string $name, int $size = 16, string $style = ''): string {\n $s = \"width:{$size}px;height:{$size}px;display:inline-block;vertical-align:middle;flex-shrink:0;\";\n if ($style) $s .= $style;\n return '<i data-lucide=\"' . e($name) . '\" style=\"' . $s . '\"></i>';\n}\n\n// Validate / normalize image URL for admin-saved fields.\n// Accepts absolute URLs, SITE_URL-relative paths (starting with '/'),\n// or empty string. Returns normalized absolute URL, empty string, or false on invalid.\n/**\n * @param string $url\n * @return string|false\n */\n/** Sanitize up to N admin-submitted gallery image URLs. */\nfunction stSanitizeDetailGalleryInput(array $urls, int $max = 2): array {\n $clean = [];\n foreach ($urls as $url) {\n $url = trim((string)$url);\n if ($url === '') {\n continue;\n }\n $normalized = normalizeImageUrl($url);\n if ($normalized === false || $normalized === '') {\n continue;\n }\n $clean[] = $normalized;\n if (count($clean) >= $max) {\n break;\n }\n }\n return array_values(array_unique($clean));\n}\n\n/** Build gallery URLs from JSON column with legacy primary-image fallback. */\nfunction stDetailGalleryImages(?string $screenshotsJson, ?string $primaryUrl = null, int $max = 2): array {\n $urls = [];\n if ($screenshotsJson !== null && trim($screenshotsJson) !== '') {\n $decoded = json_decode($screenshotsJson, true);\n if (is_array($decoded)) {\n foreach ($decoded as $url) {\n $url = trim((string)$url);\n if ($url !== '') {\n $urls[] = $url;\n }\n }\n }\n }\n if ($urls === [] && $primaryUrl !== null && trim($primaryUrl) !== '') {\n $urls[] = trim($primaryUrl);\n }\n return stSanitizeDetailGalleryInput($urls, $max);\n}\n\nfunction normalizeImageUrl(string $url) {\n $url = trim($url);\n if ($url === '') return '';\n // Allow relative site-root paths like /uploads/foo.jpg\n if (str_starts_with($url, '/')) {\n // If SITE_URL is not configured or empty, return the relative path as-is\n if (!defined('SITE_URL') || SITE_URL === '') {\n return $url; // Return relative path for development without SITE_URL\n }\n return rtrim(SITE_URL, '/') . $url;\n }\n // Ensure it's a valid absolute URL\n if (!filter_var($url, FILTER_VALIDATE_URL)) return false;\n // Basic extension check - skip slow network validation\n $ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));\n $allowedExts = ['jpg','jpeg','png','webp','gif','svg','ico'];\n if ($ext !== '' && !in_array($ext, $allowedExts, true)) {\n return false;\n }\n return $url;\n}\n\n/** Turn a relative /uploads/... path or absolute URL into a full https URL for OG/schema. */\nfunction absoluteMediaUrl(?string $url): string {\n $url = trim((string)$url);\n if ($url === '') return '';\n if (preg_match('#^https?://#i', $url)) return $url;\n if (str_starts_with($url, '//')) return 'https:' . $url;\n $base = rtrim(defined('SITE_URL') ? (string)SITE_URL : '', '/');\n if ($base === '') return $url;\n if ($url[0] !== '/') $url = '/' . $url;\n return $base . $url;\n}\n\n/**\n * Map a media URL to a local filesystem path when it belongs to this site.\n * Returns '' if the file cannot be resolved locally.\n */\nfunction localMediaPath(?string $url): string {\n $url = trim((string)$url);\n if ($url === '') return '';\n\n $path = '';\n if (preg_match('#^https?://#i', $url) || str_starts_with($url, '//')) {\n $path = (string)(parse_url($url, PHP_URL_PATH) ?? '');\n // Only treat same-site paths as local\n $host = parse_url($url, PHP_URL_HOST);\n $siteHost = defined('SITE_URL') ? parse_url((string)SITE_URL, PHP_URL_HOST) : null;\n if ($host && $siteHost && strcasecmp((string)$host, (string)$siteHost) !== 0) {\n return ''; // remote CDN — assume OK\n }\n } else {\n $path = $url[0] === '/' ? $url : '/' . $url;\n }\n if ($path === '' || $path === '/') return '';\n\n $root = dirname(__DIR__); // project root (includes/ → ../)\n // Prefer includes/ parent (project root)\n $candidates = [\n $root . $path,\n dirname($root) . $path, // safety\n ];\n // Also try from document-style public_html layout\n if (defined('SITE_URL')) {\n $candidates[] = $root . '/..' . $path;\n }\n foreach ($candidates as $fs) {\n $real = realpath($fs);\n if ($real && is_file($real)) return $real;\n if (is_file($fs)) return $fs;\n }\n // Direct check without realpath (symlink issues)\n $direct = $root . $path;\n return is_file($direct) ? $direct : '';\n}\n\n/** True if URL is usable for OG (local file exists, or remote non-site URL). */\nfunction ogImageIsUsable(?string $url): bool {\n $url = trim((string)$url);\n if ($url === '') return false;\n\n // Remote absolute URL on another host — keep (cannot verify cheaply)\n if (preg_match('#^https?://#i', $url)) {\n $host = parse_url($url, PHP_URL_HOST);\n $siteHost = defined('SITE_URL') ? parse_url((string)SITE_URL, PHP_URL_HOST) : null;\n if ($host && $siteHost && strcasecmp((string)$host, (string)$siteHost) !== 0) {\n return true;\n }\n }\n\n $local = localMediaPath($url);\n if ($local !== '') return true;\n\n // Same-site URL but file missing → unusable (would 404 → blank Facebook preview)\n return false;\n}\n\n/**\n * Social preview image: page override → Settings OG image → site logo → legacy fallback.\n * Skips missing local files so Facebook never gets a 404 white preview.\n */\nfunction resolveOgImageUrl(?string $pageOverride = null, ?array $settings = null): string {\n $settings = $settings ?? (function_exists('siteSettings') ? siteSettings() : []);\n $candidates = [\n trim((string)($pageOverride ?? '')),\n trim((string)($settings['og_image'] ?? '')),\n trim((string)($settings['logo_url'] ?? '')),\n '/public/opengraph.jpg',\n ];\n foreach ($candidates as $c) {\n if ($c === '') continue;\n if (!ogImageIsUsable($c)) continue;\n $abs = absoluteMediaUrl($c);\n if ($abs !== '') return $abs;\n }\n // Last resort absolute fallback even if file check failed (dev)\n return absoluteMediaUrl('/public/opengraph.jpg') ?: '/public/opengraph.jpg';\n}\n\n/** Prefer admin favicon, then logo (PNG/ICO), then bundled SVG. */\nfunction resolveFaviconUrl(?array $settings = null): string {\n $settings = $settings ?? (function_exists('siteSettings') ? siteSettings() : []);\n $candidates = [\n trim((string)($settings['favicon_url'] ?? '')),\n trim((string)($settings['logo_url'] ?? '')),\n '/public/favicon.svg',\n ];\n foreach ($candidates as $c) {\n if ($c === '') continue;\n if (!ogImageIsUsable($c)) continue;\n $abs = absoluteMediaUrl($c);\n if ($abs !== '') return $abs;\n }\n return absoluteMediaUrl('/public/favicon.svg') ?: '/public/favicon.svg';\n}\n\n/**\n * Apple / Facebook chrome icons prefer PNG/JPG (SVG often shows as a broken/red tile).\n */\nfunction resolveAppleTouchIconUrl(?array $settings = null): string {\n $settings = $settings ?? (function_exists('siteSettings') ? siteSettings() : []);\n $candidates = [\n trim((string)($settings['favicon_url'] ?? '')),\n trim((string)($settings['logo_url'] ?? '')),\n trim((string)($settings['og_image'] ?? '')),\n ];\n foreach ($candidates as $c) {\n if ($c === '') continue;\n if (!ogImageIsUsable($c)) continue;\n $path = strtolower((string)(parse_url(absoluteMediaUrl($c), PHP_URL_PATH) ?? $c));\n if (preg_match('/\\.(svg)(\\?|$)/', $path)) continue; // skip SVG for touch icon\n $abs = absoluteMediaUrl($c);\n if ($abs !== '') return $abs;\n }\n // Last raster fallback: site logo even if check failed, else SVG\n $logo = trim((string)($settings['logo_url'] ?? ''));\n if ($logo !== '') return absoluteMediaUrl($logo);\n return resolveFaviconUrl($settings);\n}\n\nfunction faviconMimeFromUrl(string $url): string {\n $path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?? $url));\n if (str_ends_with($path, '.svg')) return 'image/svg+xml';\n if (str_ends_with($path, '.ico')) return 'image/x-icon';\n if (str_ends_with($path, '.webp')) return 'image/webp';\n if (str_ends_with($path, '.jpg') || str_ends_with($path, '.jpeg')) return 'image/jpeg';\n return 'image/png';\n}\n\n/**\n * Default optional add-ons shown on products.php when products_addons is empty.\n * Keep in sync with the live hardcoded list so public stays unchanged until admin saves.\n */\nfunction productsAddonsDefaults(): array {\n return [\n ['active' => true, 'link_type' => 'custom', 'link_id' => 0, 'icon' => 'puzzle', 'title' => 'Custom Reports', 'desc' => 'Business / audit / management-specific reports', 'price' => 'from NPR 8,000', 'box' => 'icon-box-blue'],\n ['active' => true, 'link_type' => 'custom', 'link_id' => 0, 'icon' => 'database', 'title' => 'Data Migration', 'desc' => 'From Excel, FoxPro or legacy systems', 'price' => 'from NPR 25,000', 'box' => 'icon-box-purple'],\n ['active' => true, 'link_type' => 'custom', 'link_id' => 0, 'icon' => 'graduation-cap', 'title' => 'On-site Training', 'desc' => 'Full-day training for branch staff', 'price' => 'NPR 15,000/day', 'box' => 'icon-box-amber'],\n ['active' => true, 'link_type' => 'custom', 'link_id' => 0, 'icon' => 'plug', 'title' => 'Third-party Integration', 'desc' => 'Payment gateways, APIs, third-party services', 'price' => 'from NPR 12,000', 'box' => 'icon-box-teal'],\n ];\n}\n\n/**\n * Resolve products-page add-ons from site_settings (or defaults).\n * Linked product/service rows supply name/summary/price/icon when active.\n *\n * @return list<array{icon:string,title:string,desc:string,price:string,box:string,detail_url:?string}>\n */\nfunction resolveProductsAddons(?array $settings = null): array {\n $settings = $settings ?? siteSettings();\n $raw = $settings['products_addons'] ?? '';\n $items = [];\n if (is_string($raw) && $raw !== '') {\n $decoded = json_decode($raw, true);\n if (is_array($decoded)) $items = $decoded;\n } elseif (is_array($raw)) {\n $items = $raw;\n }\n if (empty($items)) {\n $items = productsAddonsDefaults();\n }\n\n $colorMap = [\n 'blue' => 'icon-box-blue', 'teal' => 'icon-box-teal', 'purple' => 'icon-box-purple',\n 'amber' => 'icon-box-amber', 'green' => 'icon-box-green', 'rose' => 'icon-box-rose',\n 'orange' => 'icon-box-orange', 'indigo' => 'icon-box-indigo', 'gray' => 'icon-box-gray',\n ];\n\n $out = [];\n foreach ($items as $item) {\n if (!is_array($item)) continue;\n if (isset($item['active']) && !$item['active']) continue;\n\n $linkType = (string)($item['link_type'] ?? 'custom');\n $linkId = (int)($item['link_id'] ?? 0);\n $icon = trim((string)($item['icon'] ?? '')) ?: 'puzzle';\n $title = trim((string)($item['title'] ?? ''));\n $desc = trim((string)($item['desc'] ?? ''));\n $price = trim((string)($item['price'] ?? ''));\n $box = trim((string)($item['box'] ?? 'icon-box-blue')) ?: 'icon-box-blue';\n $detailUrl = null;\n\n if ($linkType === 'product' && $linkId > 0) {\n try {\n $row = queryOne(\n \"SELECT name, slug, summary, price_from, lucide_icon, icon_color FROM products WHERE id=? AND active=1\",\n [$linkId]\n );\n if ($row) {\n if ($title === '') $title = (string)$row['name'];\n if ($desc === '') $desc = (string)($row['summary'] ?? '');\n if ($icon === 'puzzle' || empty($item['icon'])) {\n $icon = trim((string)($row['lucide_icon'] ?? '')) ?: $icon;\n }\n if ($price === '' && !empty($row['price_from'])) {\n $price = 'from NPR ' . number_format((float)$row['price_from'], 0);\n }\n $color = strtolower((string)($row['icon_color'] ?? 'blue'));\n if (empty($item['box'])) $box = $colorMap[$color] ?? $box;\n $detailUrl = url('product-detail.php?slug=' . urlencode((string)$row['slug']));\n }\n } catch (\\Throwable $e) { /* keep overrides */ }\n } elseif ($linkType === 'service' && $linkId > 0) {\n try {\n $row = queryOne(\n \"SELECT title AS name, slug, summary, price_from,\n COALESCE(lucide_icon, icon, 'layers') AS lucide_icon, icon_color\n FROM services WHERE id=? AND active=1\",\n [$linkId]\n );\n if ($row) {\n if ($title === '') $title = (string)$row['name'];\n if ($desc === '') $desc = (string)($row['summary'] ?? '');\n if ($icon === 'puzzle' || empty($item['icon'])) {\n $icon = trim((string)($row['lucide_icon'] ?? '')) ?: $icon;\n }\n if ($price === '' && !empty($row['price_from'])) {\n $price = 'from NPR ' . number_format((float)$row['price_from'], 0);\n }\n $color = strtolower((string)($row['icon_color'] ?? 'blue'));\n if (empty($item['box'])) $box = $colorMap[$color] ?? $box;\n $detailUrl = url('service-detail.php?slug=' . urlencode((string)$row['slug']));\n }\n } catch (\\Throwable $e) { /* keep overrides */ }\n }\n\n if ($title === '') continue;\n $out[] = [\n 'icon' => $icon,\n 'title' => $title,\n 'desc' => $desc,\n 'price' => $price,\n 'box' => $box,\n 'detail_url' => $detailUrl,\n ];\n }\n return $out;\n}\n\n// ── Audit log helper ─────────────────────────────────────────────\n// नेपालीमा: Admin action haru lai audit_log table ma record garne\n// Usage: logAudit('user.delete', 'Deleted user id=42', ['target_type'=>'user','target_id'=>42])\nfunction logAudit(string $action, string $description = '', array $meta = []): void {\n try {\n if (session_status() === PHP_SESSION_NONE) session_start();\n $userId = $_SESSION['user_id'] ?? null;\n $targetT = $meta['target_type'] ?? null;\n $targetId = isset($meta['target_id']) ? (int)$meta['target_id'] : null;\n $oldVal = isset($meta['old']) ? json_encode($meta['old']) : null;\n $newVal = isset($meta['new'])\n ? json_encode($meta['new'])\n : ($description !== '' ? json_encode(['note' => $description]) : null);\n $ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? null;\n if ($ip) $ip = trim(explode(',', $ip)[0]);\n $ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 300);\n\n // Try full schema (with ip_address + user_agent); fall back to slim schema.\n try {\n execute(\n \"INSERT INTO audit_log (user_id, action, target_type, target_id, old_value, new_value, ip_address, user_agent)\n VALUES (?,?,?,?,?,?,?,?)\",\n [$userId, $action, $targetT, $targetId, $oldVal, $newVal, $ip, $ua]\n );\n } catch (\\Throwable $e) {\n execute(\n \"INSERT INTO audit_log (user_id, action, target_type, target_id, new_value)\n VALUES (?,?,?,?,?)\",\n [$userId, $action, $targetT, $targetId, $newVal]\n );\n }\n } catch (\\Throwable $e) {\n // Audit failure le main flow lai block nagarcha\n error_log(\"logAudit failed: \" . $e->getMessage());\n }\n}\n"}}}}