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.
1 line
78 KiB
JSON
1 line
78 KiB
JSON
{"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"}}}} |