// Luminara — main shell
const { useState: useState_app, useEffect: useEffect_app, useMemo: useMemo_app, useRef: useRef_app } = React;

// ETH-ATLAS: research/atlas nodes that lead to a Foundations chapter instead of their own scenes.
// The "ethereum" node's old 5 subtopic scenes were retired; it now opens the ETH Atlas chapter.
const NODE_CHAPTER_REDIRECT = { ethereum: "eth-atlas" };

// RESEARCH-TOPIC-ALIASES: public URLs must use a course/topic identifier, not an
// ecosystem group.  `bitcoin` was used by the first navigation tree and is now a
// durable legacy alias for the Bitcoin Atlas course. Base is deliberately *not*
// aliased: `base` is the course overview, while `base-chNN` identifies a chapter.
// Keep this mapping at the
// routing boundary so direct links, menu navigation, browser history and shares
// converge on the one canonical URL instead of every consumer carrying a special
// case.
const RESEARCH_TOPIC_ALIASES = Object.freeze({
  bitcoin: "bitcoin-atlas",
});

// RESEARCH-HUB (#84): one catalogue drives the top tree, the sidebar landing
// page and the two group pages. Runtime content may contain historical topics,
// but navigation exposes only the product-approved information architecture.
const RESEARCH_CATALOG = Object.freeze({
  groups: Object.freeze([
    { id: "foundations" },
    { id: "ecosystems" },
    { id: "industries" },
  ]),
  ecosystems: Object.freeze([
    { id: "ton", title: { en: "TON · Telegram", ru: "TON · Telegram", uk: "TON · Telegram", kk: "TON · Telegram", uz: "TON · Telegram", es: "TON · Telegram", fr: "TON · Telegram", hy: "TON · Telegram" } },
    { id: "ethereum", chapter: "eth-atlas", title: { en: "Ethereum", ru: "Ethereum", uk: "Ethereum", kk: "Ethereum", uz: "Ethereum", es: "Ethereum", fr: "Ethereum", hy: "Ethereum" } },
    { id: "bitcoin", title: { en: "Bitcoin", ru: "Bitcoin", uk: "Bitcoin", kk: "Bitcoin", uz: "Bitcoin", es: "Bitcoin", fr: "Bitcoin", hy: "Bitcoin" } },
    { id: "base", title: { en: "Base", ru: "Base", uk: "Base", kk: "Base", uz: "Base", es: "Base", fr: "Base", hy: "Base" } },
  ]),
  industries: Object.freeze([
    { id: "ai", title: { en: "Artificial Intelligence", ru: "Искусственный интеллект", uk: "Штучний інтелект", kk: "Жасанды интеллект", uz: "Sunʼiy intellekt", es: "Inteligencia artificial", fr: "Intelligence artificielle", hy: "Արհեստական բանականություն" } },
    { id: "trading", title: { en: "Trading", ru: "Трейдинг", uk: "Трейдинг", kk: "Трейдинг", uz: "Treyding", es: "Trading", fr: "Trading", hy: "Թրեյդինգ" } },
    { id: "rwa", title: { en: "RWA", ru: "RWA", uk: "RWA", kk: "RWA", uz: "RWA", es: "RWA", fr: "RWA", hy: "RWA" } },
    { id: "gamefi", title: { en: "GameFi", ru: "GameFi", uk: "GameFi", kk: "GameFi", uz: "GameFi", es: "GameFi", fr: "GameFi", hy: "GameFi" } },
  ]),
});
const canonicalResearchTopic = (value) => {
  if (typeof value !== "string") return value;
  const key = value.trim();
  return RESEARCH_TOPIC_ALIASES[key] || key || null;
};

// NAV-CONTENT-PARENT: every readable course has exactly one product parent.
// This is deliberately keyed by durable ids, never by translated labels or the
// transient React view. It governs both labels and cold-link recovery.
const contentGroupForKey = (key) => {
  const value = String(key || "").trim();
  if (!value) return "foundations";
  if (/^eth_\d+$/i.test(value) || /^(?:eth-atlas|ethereum|ethereum-whitepaper|ton|bitcoin|bitcoin-atlas|base|base-ch\d{2})$/i.test(value)) return "ecosystems";
  if (/^(?:ai|trading|rwa|gamefi)$/i.test(value)) return "industries";
  return "foundations";
};

// C3: the version NUMBER shown in the header comes from server runtime config (set from a
// validated APP_VERSION env value), the SAME string for every locale. No hardcoded "v1.2", no
// hostname guessing. Empty when the config hasn't loaded — the badge then shows the Beta word
// alone rather than a wrong number.
const APP_VERSION = (typeof window !== "undefined" && window.LUMINARA_PUBLIC_CONFIG && window.LUMINARA_PUBLIC_CONFIG.appVersion) || "";

// ── S2-BIG.2: live session bridge ────────────────────────────────────────────
// Real mode (window.LUMINARA_DEMO === false) restores the session from the
// lum_rt refresh cookie and feeds /me into the React tree. Demo mode keeps the
// self-contained showcase. One API instance per page (access token in memory);
// exposed on window so later chunks (lessons/points, insights, moderation) reuse it.
const LUM_DEMO = (typeof window !== "undefined" && window.LUMINARA_DEMO !== false);
const LUM_API = (!LUM_DEMO && typeof window !== "undefined" && window.createLuminaraApi)
  ? window.createLuminaraApi({
      // Refresh truly failed → session is gone → full login screen.
      onUnauthorized: () => { window.location.href = "./Luminara_Auth.html"; },
    })
  : null;
if (typeof window !== "undefined") window.LUMINARA_API = LUM_API;

// URL-SUBSTATE: tiny helper to read/write a sub-state param in the hash *query*,
// keeping the #/<view> path intact. Uses replaceState so it never fires hashchange
// (no interference with the top-level router) and doesn't spam back-history. Section
// components (atlas/ecosystems/research) read their open-state from here on mount and
// sync it on change, so a refresh/share restores the exact detail screen.
if (typeof window !== "undefined" && !window.LUM_ROUTE) {
  window.LUM_ROUTE = {
    get(key) {
      const q = (window.location.hash.split("?")[1] || "");
      const value = new URLSearchParams(q).get(key) || null;
      return key === "t" ? canonicalResearchTopic(value) : value;
    },
    set(key, value) {
      const raw = window.location.hash.replace(/^#/, "");
      const parts = raw.split("?");
      const params = new URLSearchParams(parts[1] || "");
      const normalized = key === "t" ? canonicalResearchTopic(value) : value;
      if (normalized == null || normalized === "" || normalized === false) params.delete(key);
      else params.set(key, String(normalized));
      const qs = params.toString();
      const target = "#" + (parts[0] || "/atlas") + (qs ? "?" + qs : "");
      try { window.history.replaceState(null, "", target); } catch (e) {}
    },
    canonicalTopic(value) { return canonicalResearchTopic(value); },
  };
}

// Minimal splash shown while the session resolves (real mode only).
function LumBootSplash() {
  return (
    <div className="app" style={{ display: "grid", placeItems: "center", minHeight: "100vh" }}>
      <div style={{ textAlign: "center", color: "var(--text-muted)", font: "500 14px/1.5 var(--ui, 'IBM Plex Sans', system-ui)" }}>
        <span className="mark" style={{ display: "inline-block", width: 40, height: 40, marginBottom: 12 }}><LuminaraMark /></span>
        <div>Luminara…</div>
      </div>
    </div>
  );
}

// A malformed CMS value must never blank the whole application.  React's normal
// error boundary preserves the shell and gives the learner a clear recovery route;
// the incident is still logged so the offending content can be fixed rather than hidden.
class LumViewBoundary extends React.Component {
  constructor(props) { super(props); this.state = { failed: false }; }
  static getDerivedStateFromError() { return { failed: true }; }
  componentDidCatch(error, info) { console.error("Luminara view render failed", error, info); }
  render() {
    if (!this.state.failed) return this.props.children;
    return <main className="section-pad" role="alert" style={{ maxWidth: 760, paddingTop: 80 }}>
      <div className="eyebrow">Luminara</div>
      <h1>Эта страница временно недоступна</h1>
      <p>Мы сохранили остальную навигацию. Вернитесь в Атлас и попробуйте открыть раздел ещё раз.</p>
      <a className="btn primary" href="#/atlas">В Атлас</a>
    </main>;
  }
}

// DEEP-LINK-READINESS (#78): a valid article URL may be opened before the
// asynchronously loaded content catalogue is available. Keep the shell usable,
// retain the URL, and expose a recovery state instead of an empty reader.
const SCENE_ROUTE_TX = {
  loading: {
    ru: ["Открываем материал…", "Загружаем каталог уроков."],
    en: ["Opening the material…", "Loading the lesson catalogue."],
    uk: ["Відкриваємо матеріал…", "Завантажуємо каталог уроків."],
    kk: ["Материал ашылуда…", "Сабақтар каталогы жүктелуде."],
    uz: ["Material ochilmoqda…", "Darslar katalogi yuklanmoqda."],
    es: ["Abriendo el material…", "Cargando el catálogo de lecciones."],
    fr: ["Ouverture du contenu…", "Chargement du catalogue des leçons."],
    hy: ["Նյութը բացվում է…", "Դասերի կատալոգը բեռնվում է։"],
  },
  unavailable: {
    ru: ["Материал временно недоступен", "Проверьте ссылку или подключение и повторите попытку."],
    en: ["This material is temporarily unavailable", "Check the link or connection and try again."],
    uk: ["Матеріал тимчасово недоступний", "Перевірте посилання чи з’єднання і повторіть спробу."],
    kk: ["Материал уақытша қолжетімсіз", "Сілтемені не байланысты тексеріп, қайта көріңіз."],
    uz: ["Material vaqtincha mavjud emas", "Havola yoki ulanishni tekshirib, qayta urinib ko‘ring."],
    es: ["Este material no está disponible temporalmente", "Comprueba el enlace o la conexión e inténtalo de nuevo."],
    fr: ["Ce contenu est temporairement indisponible", "Vérifiez le lien ou la connexion, puis réessayez."],
    hy: ["Նյութը ժամանակավորապես հասանելի չէ", "Ստուգեք հղումը կամ կապը և փորձեք կրկին։"],
  },
};
function SceneRouteState({ locale, loading, onBack }) {
  const group = SCENE_ROUTE_TX[loading ? "loading" : "unavailable"];
  const copy = group[locale] || group.en;
  return <main className="section-pad fade-in" role={loading ? "status" : "alert"} style={{ maxWidth: 760, paddingTop: 80 }}>
    <div className="eyebrow">Luminara</div>
    <h1>{copy[0]}</h1>
    <p>{copy[1]}</p>
    {!loading && <button className="btn primary" onClick={onBack}>← {locale === "ru" ? "В Основания" : "Back to Foundations"}</button>}
  </main>;
}

// Placeholder moderation surface — role-gated entry proven here; the real panel
// (api.admin list + PATCH) is wired in S2-BIG.5.
// S2-BIG.5: real moderation. Lists submitted insights and approves/rejects them
// via api.admin (server re-checks role regardless of this client gate). CSS is
// ported from the standalone admin.js with safe token fallbacks.
const ADMIN_CSS = `
.la-access .la-chip{display:inline-flex;align-items:center;padding:5px 12px;border-radius:999px;
  border:1px solid var(--line-soft,#333);background:transparent;color:var(--text,#eee);
  font-size:12px;cursor:pointer;font-family:var(--f-mono,monospace);letter-spacing:.03em;}
.la-access .la-chip.on{background:var(--accent,oklch(55% 0.2 265));color:#fff;border-color:transparent;}
.la-access .la-select,.la-access .la-input{padding:6px 10px;border-radius:10px;
  border:1px solid var(--line-soft,#333);background:var(--bg-card,#1a1a26);color:var(--text,#eee);font-size:13px;}
[data-theme="light"] .la-access .la-select,[data-theme="light"] .la-access .la-input{background:#fff;color:#222;}
.la-access .la-input{min-width:180px;flex:1;}
.la-access .la-btn.ok{background:oklch(55% 0.16 150);color:#fff;border:none;}
.la-access .la-btn.danger{background:oklch(55% 0.19 25);color:#fff;border:none;}
[data-theme="light"] .la-access .la-btn.ok{background:oklch(46% 0.18 155);}
[data-theme="light"] .la-access .la-btn.danger{background:oklch(48% 0.2 27);}
.la-access .la-access-row{margin-bottom:10px;}
.la-access .la-dim{opacity:.6;}
.la{font-family:var(--f-sans,sans-serif);color:var(--text,#eee);width:100%;max-width:980px;
  background:linear-gradient(160deg,var(--bg-card,#1a1a26),oklch(14% 0.04 var(--accent-hue,295)));
  border:1px solid var(--line-soft,#333);border-radius:var(--r-lg,22px);padding:22px;}
[data-theme="light"] .la{background:var(--bg-card,#fff);}
.la-head{margin-bottom:14px;}
.la-title{font-family:var(--f-display,sans-serif);font-weight:700;font-size:19px;}
.la-sub{font-family:var(--f-mono,monospace);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent-2,#9cf);margin-top:3px;}
.la-tabs{display:flex;flex-wrap:wrap;gap:6px;background:var(--bg-surface,#222);border:1px solid var(--line-soft,#333);border-radius:12px;padding:4px;margin-bottom:14px;}
.la-tab{flex:1;min-width:92px;padding:8px 6px;border-radius:9px;border:none;background:transparent;color:var(--text-muted,#999);font:600 12.5px var(--f-sans,sans-serif);cursor:pointer;transition:.13s;}
.la-tab:hover{color:var(--text,#eee);}
.la-tab.on{background:var(--bg-tint,#2a2a3a);color:var(--text,#fff);}
.la-tab b{font-family:var(--f-mono,monospace);font-size:10px;opacity:.7;margin-left:4px;}
.la-body{min-height:120px;}
.la-msg{display:flex;align-items:center;justify-content:center;min-height:120px;color:var(--text-muted,#999);font-size:13px;text-align:center;}
.la-card{background:var(--bg-surface,#222);border:1px solid var(--line-soft,#333);border-radius:12px;padding:13px 14px;margin-bottom:9px;}
.la-top{display:flex;align-items:center;gap:8px;margin-bottom:7px;flex-wrap:wrap;}
.la-topic{font-family:var(--f-mono,monospace);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent-2,#9cf);background:oklch(30% 0.06 250/.4);border-radius:6px;padding:2px 7px;}
.la-author{font-size:11.5px;color:var(--text-mid,#bbb);}
.la-date{font-family:var(--f-mono,monospace);font-size:10px;color:var(--text-dim,#777);margin-left:auto;}
.la-text{font-size:13.5px;line-height:1.55;color:var(--text,#eee);white-space:pre-wrap;word-break:break-word;}
.la-foot{display:flex;align-items:center;gap:8px;margin-top:10px;}
.la-badge{font-family:var(--f-mono,monospace);font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;border-radius:5px;padding:2px 6px;border:1px solid;}
.la-badge.pend{color:var(--warn,#e0a23a);border-color:var(--warn,#e0a23a);}
.la-badge.pub{color:var(--ok,#39d98a);border-color:var(--ok,#39d98a);}
.la-badge.rej{color:var(--text-dim,#777);border-color:var(--line,#444);}
.la-act{margin-left:auto;display:flex;gap:7px;}
.la-btn{font:600 12px var(--f-sans,sans-serif);border-radius:8px;padding:6px 12px;cursor:pointer;border:1px solid;background:transparent;transition:.13s;}
.la-btn[disabled]{opacity:.45;cursor:default;}
.la-btn.ok{color:var(--ok,#39d98a);border-color:var(--ok,#39d98a);}
.la-btn.ok:hover:not([disabled]){background:oklch(45% 0.12 150/.18);}
.la-btn.no{color:var(--err,#e0526a);border-color:var(--err,#e0526a);}
.la-btn.no:hover:not([disabled]){background:oklch(45% 0.12 20/.18);}
`;
const ADMIN_T = {
  ru: { title:"Модерация", sub:"Инсайты на ревью", pending:"На модерации", published:"Опубликовано", rejected:"Отклонено", approve:"Одобрить", reject:"Отклонить", hide:"Скрыть", restore:"Вернуть", loading:"Загрузка…", empty:"Здесь пусто.", err:"Не удалось загрузить.", notAdmin:"Доступно только модераторам.", anon:"Аноним", back:"Назад" },
  en: { title:"Moderation", sub:"Insights in review", pending:"In review", published:"Published", rejected:"Rejected", approve:"Approve", reject:"Reject", hide:"Hide", restore:"Restore", loading:"Loading…", empty:"Nothing here.", err:"Could not load.", notAdmin:"Moderators only.", anon:"Anon", back:"Back" },
  uk: { title:"Модерація", sub:"Інсайти на ревʼю", pending:"На модерації", published:"Опубліковано", rejected:"Відхилено", approve:"Схвалити", reject:"Відхилити", hide:"Сховати", restore:"Повернути", loading:"Завантаження…", empty:"Тут порожньо.", err:"Не вдалося завантажити.", notAdmin:"Лише для модераторів.", anon:"Анонім", back:"Назад" },
  kk: { title:"Модерация", sub:"Шолудағы инсайттар", pending:"Модерацияда", published:"Жарияланды", rejected:"Қабылданбады", approve:"Мақұлдау", reject:"Қабылдамау", hide:"Жасыру", restore:"Қайтару", loading:"Жүктелуде…", empty:"Бос.", err:"Жүктеу мүмкін болмады.", notAdmin:"Тек модераторларға.", anon:"Аноним", back:"Артқа" },
  uz: { title:"Moderatsiya", sub:"Koʻrikdagi insaytlar", pending:"Koʻrikda", published:"Eʼlon qilindi", rejected:"Rad etildi", approve:"Tasdiqlash", reject:"Rad etish", hide:"Yashirish", restore:"Qaytarish", loading:"Yuklanmoqda…", empty:"Boʻsh.", err:"Yuklab boʻlmadi.", notAdmin:"Faqat moderatorlar uchun.", anon:"Anonim", back:"Orqaga" },
  es: { title:"Moderación", sub:"Ideas en revisión", pending:"En revisión", published:"Publicadas", rejected:"Rechazadas", approve:"Aprobar", reject:"Rechazar", hide:"Ocultar", restore:"Restaurar", loading:"Cargando…", empty:"No hay elementos.", err:"No se pudo cargar.", notAdmin:"Solo para moderadores.", anon:"Anónimo", back:"Atrás" },
  fr: { title:"Modération", sub:"Idées à examiner", pending:"À examiner", published:"Publiées", rejected:"Refusées", approve:"Approuver", reject:"Refuser", hide:"Masquer", restore:"Restaurer", loading:"Chargement…", empty:"Aucun élément.", err:"Chargement impossible.", notAdmin:"Réservé aux modérateurs.", anon:"Anonyme", back:"Retour" },
  hy: { title:"Մոդերացիա", sub:"Ստուգման ենթակա գաղափարներ", pending:"Ստուգման մեջ", published:"Հրապարակված", rejected:"Մերժված", approve:"Հաստատել", reject:"Մերժել", hide:"Թաքցնել", restore:"Վերականգնել", loading:"Բեռնվում է…", empty:"Դատարկ է։", err:"Չհաջողվեց բեռնել։", notAdmin:"Միայն մոդերատորների համար։", anon:"Անանուն", back:"Հետ" },
};
const ADMIN_TABS = ["pending", "published", "rejected"];

const ADMIN_SUB_T = {
  en: { pending:"Insights awaiting review", published:"Published insights", rejected:"Rejected insights", users:"User journal and dossiers", referrals:"Referral programme", audit:"Action log", referral_codes:"Referral code management", roles:"Team roles and permissions", access:"Access requests and grants" },
  ru: { pending:"Инсайты, ожидающие проверки", published:"Опубликованные инсайты", rejected:"Отклонённые инсайты", users:"Журнал пользователей и досье", referrals:"Реферальная программа", audit:"Журнал действий", referral_codes:"Управление реферальными кодами", roles:"Роли и права команды", access:"Заявки на доступ и права" },
  uk: { pending:"Інсайти, що очікують перевірки", published:"Опубліковані інсайти", rejected:"Відхилені інсайти", users:"Журнал користувачів і досьє", referrals:"Реферальна програма", audit:"Журнал дій", referral_codes:"Керування реферальними кодами", roles:"Ролі та права команди", access:"Запити на доступ і права" },
  kk: { pending:"Тексеруді күтетін инсайттар", published:"Жарияланған инсайттар", rejected:"Қабылданбаған инсайттар", users:"Пайдаланушылар журналы мен досьелер", referrals:"Реферал бағдарламасы", audit:"Әрекеттер журналы", referral_codes:"Реферал кодтарын басқару", roles:"Команда рөлдері мен құқықтары", access:"Кіру өтінімдері мен құқықтар" },
  uz: { pending:"Tekshiruvni kutayotgan insaytlar", published:"E’lon qilingan insaytlar", rejected:"Rad etilgan insaytlar", users:"Foydalanuvchilar jurnali va dosyelari", referrals:"Referal dasturi", audit:"Harakatlar jurnali", referral_codes:"Referal kodlarini boshqarish", roles:"Jamoa rollari va huquqlari", access:"Kirish so‘rovlari va huquqlar" },
  es: { pending:"Ideas pendientes de revisión", published:"Ideas publicadas", rejected:"Ideas rechazadas", users:"Registro y expedientes de usuarios", referrals:"Programa de referidos", audit:"Registro de acciones", referral_codes:"Gestión de códigos de referido", roles:"Roles y permisos del equipo", access:"Solicitudes de acceso y permisos" },
  fr: { pending:"Idées en attente d’examen", published:"Idées publiées", rejected:"Idées refusées", users:"Journal et dossiers utilisateurs", referrals:"Programme de parrainage", audit:"Journal des actions", referral_codes:"Gestion des codes de parrainage", roles:"Rôles et droits de l’équipe", access:"Demandes d’accès et droits" },
  hy: { pending:"Ստուգման սպասող գաղափարներ", published:"Հրապարակված գաղափարներ", rejected:"Մերժված գաղափարներ", users:"Օգտատերերի մատյան և դոսյեներ", referrals:"Հրավերների ծրագիր", audit:"Գործողությունների մատյան", referral_codes:"Հրավերի կոդերի կառավարում", roles:"Թիմի դերեր և իրավունքներ", access:"Մուտքի հայտեր և իրավունքներ" },
};

const ROLE_T = {
  ru: { tab:"Роли · Команда", sub:"Управление ролями команды", searchPh:"Имя или ID пользователя", search:"Найти", searching:"Поиск…", none:"Никого не найдено.", hint:"Введите имя или ID и нажмите «Найти».", grant:"Назначить", revoke:"Снять", admin:"Админ", moderator:"Модератор", superadmin:"Суперадмин", user:"Пользователь", note:"Изменение вступит в силу при следующем входе пользователя.", err:"Ошибка. Попробуйте ещё раз." },
  en: { tab:"Roles · Team", sub:"Team role management", searchPh:"Name or user ID", search:"Search", searching:"Searching…", none:"No users found.", hint:"Enter a name or user ID and press Search.", grant:"Grant", revoke:"Revoke", admin:"Admin", moderator:"Moderator", superadmin:"Superadmin", user:"User", note:"Takes effect on the user's next login.", err:"Something went wrong. Try again." },
  uk: { tab:"Ролі · Команда", sub:"Керування ролями команди", searchPh:"Імʼя або ID користувача", search:"Знайти", searching:"Пошук…", none:"Нікого не знайдено.", hint:"Введіть імʼя або ID і натисніть «Знайти».", grant:"Призначити", revoke:"Зняти", admin:"Адмін", moderator:"Модератор", superadmin:"Суперадмін", user:"Користувач", note:"Зміна набуде чинності при наступному вході користувача.", err:"Помилка. Спробуйте ще раз." },
  kk: { tab:"Рөлдер · Команда", sub:"Команда рөлдерін басқару", searchPh:"Аты немесе пайдаланушы ID", search:"Табу", searching:"Іздеу…", none:"Ешкім табылмады.", hint:"Атын немесе ID енгізіп, «Табу» басыңыз.", grant:"Тағайындау", revoke:"Алу", admin:"Админ", moderator:"Модератор", superadmin:"Суперадмин", user:"Пайдаланушы", note:"Өзгеріс пайдаланушы келесі кірген кезде күшіне енеді.", err:"Қате. Қайталап көріңіз." },
  uz: { tab:"Rollar · Jamoa", sub:"Jamoa rollarini boshqarish", searchPh:"Ism yoki foydalanuvchi ID", search:"Qidirish", searching:"Qidirilmoqda…", none:"Hech kim topilmadi.", hint:"Ism yoki ID kiriting va «Qidirish» bosing.", grant:"Tayinlash", revoke:"Olib tashlash", admin:"Admin", moderator:"Moderator", superadmin:"Superadmin", user:"Foydalanuvchi", note:"Oʻzgarish foydalanuvchi keyingi kirganda kuchga kiradi.", err:"Xatolik. Qayta urinib koʻring." },
  es: { tab:"Roles · Equipo", sub:"Gestión de roles del equipo", searchPh:"Nombre o ID de usuario", search:"Buscar", searching:"Buscando…", none:"No se encontraron usuarios.", hint:"Escribe un nombre o ID y pulsa Buscar.", grant:"Asignar", revoke:"Quitar", admin:"Admin", moderator:"Moderador", superadmin:"Superadmin", user:"Usuario", note:"Se aplica en el próximo inicio de sesión del usuario.", err:"Algo salió mal. Inténtalo de nuevo." },
  fr: { tab:"Rôles · Équipe", sub:"Gestion des rôles de l'équipe", searchPh:"Nom ou ID utilisateur", search:"Chercher", searching:"Recherche…", none:"Aucun utilisateur trouvé.", hint:"Saisissez un nom ou un ID puis Chercher.", grant:"Attribuer", revoke:"Retirer", admin:"Admin", moderator:"Modérateur", superadmin:"Superadmin", user:"Utilisateur", note:"Prend effet à la prochaine connexion de l'utilisateur.", err:"Une erreur est survenue. Réessayez." },
  hy: { tab:"Դերեր · Թիմ", sub:"Թիմի դերերի կառավարում", searchPh:"Անուն կամ օգտատիրոջ ID", search:"Փնտրել", searching:"Որոնում…", none:"Ոչ ոք չի գտնվել։", hint:"Մուտքագրեք անուն կամ ID և սեղմեք Փնտրել։", grant:"Նշանակել", revoke:"Հանել", admin:"Ադմին", moderator:"Մոդերատոր", superadmin:"Սուպերադմին", user:"Օգտատեր", note:"Փոփոխությունն ուժի մեջ կմտնի օգտատիրոջ հաջորդ մուտքի ժամանակ։", err:"Սխալ առաջացավ։ Փորձեք նորից։" },
};

// ── ACCESS GATE (Phase 2): admin management of access requests & grants ──
const ACCESS_ADMIN_T = {
  ru: { tab:"Доступ", pending:"Ожидают", approved:"Разрешены", rejected:"Отклонены", revoked:"Отозваны",
    all:"Все", staging:"Staging", production:"Production", searchPh:"Имя, @username или Telegram ID",
    search:"Найти", loading:"Загрузка…", empty:"Пусто.", err:"Не удалось загрузить.",
    approve:"Одобрить", reject:"Отклонить", revoke:"Отозвать", extend:"Продлить",
    reasonPh:"Причина (обязательно)", notePh:"Заметка (необязательно)", expiry:"Срок (необязательно)",
    env:"Окружение", attempts:"попыток", masked:"скрыт", confirmRevoke:"Отозвать доступ пользователя?",
    prodSuperOnly:"Production доступен только суперадмину", done:"Готово", cancel:"Отмена", readOnlyHere:"только просмотр", searchPhNoId:"Имя или @username", grandfathered:"перенесён" },
  en: { tab:"Access", pending:"Pending", approved:"Approved", rejected:"Rejected", revoked:"Revoked",
    all:"All", staging:"Staging", production:"Production", searchPh:"Name, @username or Telegram ID",
    search:"Search", loading:"Loading…", empty:"Nothing here.", err:"Could not load.",
    approve:"Approve", reject:"Reject", revoke:"Revoke", extend:"Extend",
    reasonPh:"Reason (required)", notePh:"Note (optional)", expiry:"Expiry (optional)",
    env:"Environment", attempts:"attempts", masked:"masked", confirmRevoke:"Revoke this user's access?",
    prodSuperOnly:"Production is superadmin-only", done:"Done", cancel:"Cancel", readOnlyHere:"read-only", searchPhNoId:"Name or @username", grandfathered:"grandfathered" },
  uk: { tab:"Доступ", pending:"Очікують", approved:"Дозволені", rejected:"Відхилені", revoked:"Відкликані",
    all:"Усі", staging:"Staging", production:"Production", searchPh:"Імʼя, @username або Telegram ID",
    search:"Знайти", loading:"Завантаження…", empty:"Порожньо.", err:"Не вдалося завантажити.",
    approve:"Схвалити", reject:"Відхилити", revoke:"Відкликати", extend:"Продовжити",
    reasonPh:"Причина (обовʼязково)", notePh:"Нотатка (необовʼязково)", expiry:"Термін (необовʼязково)",
    env:"Оточення", attempts:"спроб", masked:"прихований", confirmRevoke:"Відкликати доступ користувача?",
    prodSuperOnly:"Production лише для суперадміна", done:"Готово", cancel:"Скасувати", readOnlyHere:"лише перегляд", searchPhNoId:"Імʼя або @username", grandfathered:"перенесений" },
  kk: { tab:"Кіру", pending:"Күтуде", approved:"Рұқсат етілген", rejected:"Қабылданбаған", revoked:"Қайтарылған",
    all:"Барлығы", staging:"Staging", production:"Production", searchPh:"Аты, @username немесе Telegram ID",
    search:"Табу", loading:"Жүктелуде…", empty:"Бос.", err:"Жүктеу мүмкін болмады.",
    approve:"Мақұлдау", reject:"Қабылдамау", revoke:"Қайтару", extend:"Ұзарту",
    reasonPh:"Себеп (міндетті)", notePh:"Ескертпе (міндетті емес)", expiry:"Мерзім (міндетті емес)",
    env:"Орта", attempts:"әрекет", masked:"жасырын", confirmRevoke:"Пайдаланушы кіруін қайтару?",
    prodSuperOnly:"Production тек суперадминге", done:"Дайын", cancel:"Болдырмау", readOnlyHere:"тек қарау", searchPhNoId:"Аты немесе @username", grandfathered:"көшірілген" },
  uz: { tab:"Kirish", pending:"Kutilmoqda", approved:"Ruxsat berilgan", rejected:"Rad etilgan", revoked:"Bekor qilingan",
    all:"Barchasi", staging:"Staging", production:"Production", searchPh:"Ism, @username yoki Telegram ID",
    search:"Qidirish", loading:"Yuklanmoqda…", empty:"Boʻsh.", err:"Yuklab boʻlmadi.",
    approve:"Tasdiqlash", reject:"Rad etish", revoke:"Bekor qilish", extend:"Uzaytirish",
    reasonPh:"Sabab (majburiy)", notePh:"Izoh (ixtiyoriy)", expiry:"Muddat (ixtiyoriy)",
    env:"Muhit", attempts:"urinish", masked:"yashirin", confirmRevoke:"Foydalanuvchi kirishini bekor qilinsinmi?",
    prodSuperOnly:"Production faqat superadmin uchun", done:"Tayyor", cancel:"Bekor qilish", readOnlyHere:"faqat koʻrish", searchPhNoId:"Ism yoki @username", grandfathered:"koʻchirilgan" },
  es: { tab:"Acceso", pending:"Pendientes", approved:"Aprobados", rejected:"Rechazados", revoked:"Revocados",
    all:"Todos", staging:"Staging", production:"Production", searchPh:"Nombre, @username o Telegram ID",
    search:"Buscar", loading:"Cargando…", empty:"Nada aquí.", err:"No se pudo cargar.",
    approve:"Aprobar", reject:"Rechazar", revoke:"Revocar", extend:"Extender",
    reasonPh:"Motivo (obligatorio)", notePh:"Nota (opcional)", expiry:"Vencimiento (opcional)",
    env:"Entorno", attempts:"intentos", masked:"oculto", confirmRevoke:"¿Revocar el acceso del usuario?",
    prodSuperOnly:"Production solo para superadmin", done:"Listo", cancel:"Cancelar", readOnlyHere:"solo lectura", searchPhNoId:"Nombre o @username", grandfathered:"heredado" },
  fr: { tab:"Accès", pending:"En attente", approved:"Approuvés", rejected:"Refusés", revoked:"Révoqués",
    all:"Tous", staging:"Staging", production:"Production", searchPh:"Nom, @username ou Telegram ID",
    search:"Chercher", loading:"Chargement…", empty:"Rien ici.", err:"Échec du chargement.",
    approve:"Approuver", reject:"Refuser", revoke:"Révoquer", extend:"Prolonger",
    reasonPh:"Motif (obligatoire)", notePh:"Note (facultatif)", expiry:"Expiration (facultatif)",
    env:"Environnement", attempts:"tentatives", masked:"masqué", confirmRevoke:"Révoquer l’accès de l’utilisateur ?",
    prodSuperOnly:"Production réservé au superadmin", done:"Terminé", cancel:"Annuler", readOnlyHere:"lecture seule", searchPhNoId:"Nom ou @username", grandfathered:"hérité" },
  hy: { tab:"Մուտք", pending:"Սպասում են", approved:"Թույլատրված", rejected:"Մերժված", revoked:"Հետ կանչված",
    all:"Բոլորը", staging:"Staging", production:"Production", searchPh:"Անուն, @username կամ Telegram ID",
    search:"Փնտրել", loading:"Բեռնում…", empty:"Դատարկ է։", err:"Չհաջողվեց բեռնել։",
    approve:"Հաստատել", reject:"Մերժել", revoke:"Հետ կանչել", extend:"Երկարաձգել",
    reasonPh:"Պատճառ (պարտադիր)", notePh:"Նշում (ըստ ցանկության)", expiry:"Ժամկետ (ըստ ցանկության)",
    env:"Միջավայր", attempts:"փորձ", masked:"թաքցված", confirmRevoke:"Հետ կանչե՞լ օգտատիրոջ մուտքը։",
    prodSuperOnly:"Production-ը միայն սուպերադմինի համար", done:"Պատրաստ է", cancel:"Չեղարկել", readOnlyHere:"միայն դիտում", searchPhNoId:"Անուն կամ @username", grandfathered:"փոխանցված" },
};

function AccessManager({ api, locale, isSuper }) {
  const t = ACCESS_ADMIN_T[locale] || ACCESS_ADMIN_T.en;
  const [status, setStatus] = useState_app("pending");
  const [q, setQ] = useState_app("");
  const [items, setItems] = useState_app(null);
  const [serverEnv, setServerEnv] = useState_app("");
  const [canMutate, setCanMutate] = useState_app(false);
  const [loading, setLoading] = useState_app(true);
  const [error, setError] = useState_app(false);
  const [busy, setBusy] = useState_app({});

  const load = async () => {
    if (!api) { setLoading(false); setError(true); return; }
    setLoading(true); setError(false);
    try {
      // Environment is server-authoritative; this admin UI manages ONLY the server's env.
      // "all" means no status filter at all (server has no "all" status; omit the param).
      const r = await api.admin.accessList(status === "all" ? { q, limit: 50 } : { status, q, limit: 50 });
      setItems(Array.isArray(r.items) ? r.items : []);
      setServerEnv(r.server_environment || r.environment || "");
      setCanMutate(!!r.can_mutate);
    } catch (e) { setError(true); setItems([]); }
    setLoading(false);
  };
  useEffect_app(() => { load(); /* eslint-disable-next-line */ }, [status]);

  const keyOf = (it) => it.request_id || `u:${it.user_id}`;
  const mark = (id, v) => setBusy(b => ({ ...b, [id]: v }));

  const doApprove = async (it) => {
    const id = keyOf(it); mark(id, true);
    try { await api.admin.accessApprove(it.request_id, {}); await load(); }
    catch (e) { alert(t.err); } finally { mark(id, false); }
  };
  const doReject = async (it) => {
    const reason = window.prompt(t.reasonPh); if (!reason) return;
    const id = keyOf(it); mark(id, true);
    try { await api.admin.accessReject(it.request_id, reason); await load(); }
    catch (e) { alert(t.err); } finally { mark(id, false); }
  };
  const doRevoke = async (it) => {
    if (!window.confirm(t.confirmRevoke)) return;
    const reason = window.prompt(t.reasonPh); if (!reason) return;
    const id = keyOf(it); mark(id, true);
    try { await api.admin.accessRevoke(it.user_id, { reason }); await load(); }
    catch (e) { alert(t.err); } finally { mark(id, false); }
  };

  // Revision 3 P1 fix: grandfathered grants (no request row) were listed by the backend
  // (FULL OUTER JOIN) but had no tab that could ever request them — "all" and "grandfathered"
  // close that gap so an admin can actually find and manage them.
  const statusTabs = ["all", "pending", "approved", "rejected", "revoked", "grandfathered"];

  return (
    <div className="la-access">
      <div className="la-dim" style={{ fontSize:12, marginBottom:8, fontFamily:"var(--f-mono)", textTransform:"uppercase", letterSpacing:".08em" }}>
        {t.env}: {serverEnv || "—"}{!canMutate ? ` · ${t.readOnlyHere}` : ""}
      </div>
      <div className="la-filters" style={{ display:"flex", gap:8, flexWrap:"wrap", marginBottom:10 }}>
        {statusTabs.map(s => (
          <button key={s} className={"la-chip" + (status === s ? " on" : "")} onClick={() => setStatus(s)}>{t[s]}</button>
        ))}
        <input className="la-input" value={q} placeholder={isSuper ? t.searchPh : t.searchPhNoId}
          onChange={e => setQ(e.target.value)} onKeyDown={e => { if (e.key === "Enter") load(); }} />
        <button className="la-btn" onClick={load}>{t.search}</button>
      </div>
      {loading ? <div className="la-msg">{t.loading}</div>
        : error ? <div className="la-msg">{t.err}</div>
        : !items || items.length === 0 ? <div className="la-msg">{t.empty}</div>
        : items.map(it => (
          <div key={keyOf(it)} className="la-card la-access-row">
            <div className="la-access-head">
              <b>{it.display_name || it.username || "—"}</b>
              {it.username ? <span className="la-dim"> @{it.username}</span> : null}
              <span className="la-dim"> · {it.external_id || t.masked}{it.external_id_masked ? ` (${t.masked})` : ""}</span>
              {/* Safe manage handle: a grant-only (grandfathered) row may still show no name/
                  username, so always give the admin a short, stable id to act on — never blind. */}
              {!it.display_name && !it.username ? (
                <span className="la-dim"> · #{String(it.user_id || "").slice(0, 8)}</span>
              ) : null}
            </div>
            <div className="la-dim" style={{ fontSize:12 }}>
              {it.environment} · {it.status}{it.grant_status && it.grant_status !== it.status ? ` → ${it.grant_status}` : ""}
              {it.grant_expires_at ? ` · ${new Date(it.grant_expires_at).toLocaleDateString()}` : ""}
              {it.attempts ? ` · ${it.attempts} ${t.attempts}` : ""}
              {it.grant_source === "migration" ? ` · ${t.grandfathered}` : ""}
            </div>
            {canMutate ? (
              <div className="la-access-actions" style={{ display:"flex", gap:6, flexWrap:"wrap", marginTop:8 }}>
                {it.request_status === "pending" ? <>
                  <button className="la-btn ok" disabled={busy[keyOf(it)]} onClick={() => doApprove(it)}>{t.approve}</button>
                  <button className="la-btn danger" disabled={busy[keyOf(it)]} onClick={() => doReject(it)}>{t.reject}</button>
                </> : null}
                {(it.grant_status === "approved" || it.grant_status === "grandfathered") ? (
                  <button className="la-btn danger" disabled={busy[keyOf(it)]} onClick={() => doRevoke(it)}>{t.revoke}</button>
                ) : null}
              </div>
            ) : null}
          </div>
        ))}
    </div>
  );
}

// ADMIN-ROLE-MANAGEMENT: superadmin-only tab — search users and grant/revoke admin|moderator.
// The server enforces superadmin; this UI is only rendered when the caller is superadmin.
function RolesManager({ api, locale }) {
  const rtx = ROLE_T[locale] || ROLE_T.en;
  const [q, setQ] = useState_app("");
  const [users, setUsers] = useState_app(null);   // null = not searched yet, [] = none, [...] = results
  const [loading, setLoading] = useState_app(false);
  const [error, setError] = useState_app(false);
  const [busy, setBusy] = useState_app({});

  const hasRole = (u, r) => Array.isArray(u.roles) && u.roles.indexOf(r) !== -1;

  const doSearch = async () => {
    if (!api) return;
    setLoading(true); setError(false);
    try { const r = await api.admin.usersSearch(q.trim(), 50); setUsers((r && r.users) || []); }
    catch (e) { setError(true); setUsers([]); }
    finally { setLoading(false); }
  };
  const toggle = async (u, roleName) => {
    const bkey = u.id + ":" + roleName;
    if (busy[bkey] || !api) return;
    setBusy(b => ({ ...b, [bkey]: true })); setError(false);
    const had = hasRole(u, roleName);
    try {
      const r = had ? await api.admin.revokeRole(u.id, roleName) : await api.admin.grantRole(u.id, roleName);
      const roles = (r && Array.isArray(r.roles)) ? r.roles
        : (had ? (u.roles || []).filter(x => x !== roleName) : (u.roles || []).concat(roleName));
      setUsers(list => list.map(x => x.id === u.id ? { ...x, roles } : x));
    } catch (e) { setError(true); }
    finally { setBusy(b => { const n = { ...b }; delete n[bkey]; return n; }); }
  };

  return (
    <div className="rm">
      <div className="rm-note">{rtx.note}</div>
      <div className="rm-search">
        <input className="rm-input" value={q} placeholder={rtx.searchPh}
               onChange={e => setQ(e.target.value)}
               onKeyDown={e => { if (e.key === "Enter") doSearch(); }} />
        <button className="la-btn ok" onClick={doSearch} disabled={loading}>{loading ? rtx.searching : rtx.search}</button>
      </div>
      {error ? <div className="la-msg">{rtx.err}</div> : null}
      {users === null ? <div className="la-msg">{rtx.hint}</div>
        : users.length === 0 ? <div className="la-msg">{rtx.none}</div>
        : users.map(u => {
            const isSuper = hasRole(u, "superadmin");
            const shown = (u.roles && u.roles.length) ? u.roles : ["user"];
            return (
              <div key={u.id} className="rm-row">
                <div className="rm-id">
                  <div className="rm-name">{u.display_name || u.id}</div>
                  <div className="rm-roles">
                    {shown.map(rn => <span key={rn} className={"rm-badge rm-" + rn}>{rtx[rn] || rn}</span>)}
                  </div>
                </div>
                <div className="rm-actions">
                  {isSuper ? <span className="rm-locked">{rtx.superadmin}</span>
                    : ["admin", "moderator"].map(rn => {
                        const on = hasRole(u, rn);
                        const bkey = u.id + ":" + rn;
                        return (
                          <button key={rn} className={"la-btn " + (on ? "no" : "ok")} disabled={!!busy[bkey]}
                                  onClick={() => toggle(u, rn)}>
                            {(on ? rtx.revoke : rtx.grant) + " · " + (rtx[rn] || rn)}
                          </button>
                        );
                      })}
                </div>
              </div>
            );
          })}
    </div>
  );
}

const REF_ADMIN_T = {
  en: { tab:"Referrals", codes:"Referral codes", search:"Search name or ID", all:"All statuses", suspicious:"Suspicious only", load:"Refresh", loading:"Loading…", empty:"No referrals.", err:"Could not load referrals.", approve:"Approve", reject:"Reject", fraud:"Fraud", restore:"Restore", referrer:"Referrer", referred:"Referred", source:"Source", attribution:"Attribution", campaign:"Campaign", applied:"Applied", convertedAt:"Converted", awardedAt:"Awarded", create:"Create code", owner:"Owner user UUID (optional)", code:"Code (optional)", label:"Label", max:"Max uses", expires:"Expires", deactivate:"Deactivate", active:"Active", uses:"uses" },
  ru: { tab:"Рефералы", codes:"Реферальные коды", search:"Имя или ID", all:"Все статусы", suspicious:"Только подозрительные", load:"Обновить", loading:"Загрузка…", empty:"Рефералов нет.", err:"Не удалось загрузить рефералы.", approve:"Подтвердить", reject:"Отклонить", fraud:"Фрод", restore:"Восстановить", referrer:"Пригласил", referred:"Приглашён", source:"Источник", attribution:"Атрибуция", campaign:"Кампания", applied:"Применён", convertedAt:"Подтверждён", awardedAt:"Начислен", create:"Создать код", owner:"UUID владельца (необязательно)", code:"Код (необязательно)", label:"Название", max:"Лимит", expires:"Истекает", deactivate:"Отключить", active:"Активен", uses:"исп." },
  uk: { tab:"Реферали", codes:"Реферальні коди", search:"Ім'я або ID", all:"Усі статуси", suspicious:"Лише підозрілі", load:"Оновити", loading:"Завантаження…", empty:"Рефералів немає.", err:"Не вдалося завантажити.", approve:"Підтвердити", reject:"Відхилити", fraud:"Фрод", restore:"Відновити", referrer:"Запросив", referred:"Запрошений", source:"Джерело", attribution:"Атрибуція", campaign:"Кампанія", applied:"Застосовано", convertedAt:"Підтверджено", awardedAt:"Нараховано", create:"Створити код", owner:"UUID власника (необов'язково)", code:"Код (необов'язково)", label:"Назва", max:"Ліміт", expires:"Спливає", deactivate:"Вимкнути", active:"Активний", uses:"вик." },
  kk: { tab:"Рефералдар", codes:"Реферал кодтары", search:"Аты немесе ID", all:"Барлық мәртебе", suspicious:"Тек күмәнді", load:"Жаңарту", loading:"Жүктелуде…", empty:"Реферал жоқ.", err:"Жүктеу мүмкін болмады.", approve:"Растау", reject:"Қабылдамау", fraud:"Алаяқтық", restore:"Қалпына келтіру", referrer:"Шақырған", referred:"Шақырылған", source:"Дереккөз", attribution:"Атрибуция", campaign:"Кампания", applied:"Қолданылды", convertedAt:"Расталды", awardedAt:"Есептелді", create:"Код жасау", owner:"Иесінің UUID (міндетті емес)", code:"Код (міндетті емес)", label:"Атауы", max:"Лимит", expires:"Аяқталады", deactivate:"Өшіру", active:"Белсенді", uses:"қолд." },
  uz: { tab:"Referallar", codes:"Referal kodlari", search:"Ism yoki ID", all:"Barcha holatlar", suspicious:"Faqat shubhali", load:"Yangilash", loading:"Yuklanmoqda…", empty:"Referallar yo'q.", err:"Yuklab bo'lmadi.", approve:"Tasdiqlash", reject:"Rad etish", fraud:"Firib", restore:"Tiklash", referrer:"Taklif qilgan", referred:"Taklif qilingan", source:"Manba", attribution:"Atributsiya", campaign:"Kampaniya", applied:"Qo'llangan", convertedAt:"Tasdiqlangan", awardedAt:"Hisoblangan", create:"Kod yaratish", owner:"Egasi UUID (ixtiyoriy)", code:"Kod (ixtiyoriy)", label:"Nomi", max:"Limit", expires:"Tugaydi", deactivate:"O'chirish", active:"Faol", uses:"ishl." },
  es: { tab:"Referidos", codes:"Códigos de referido", search:"Nombre o ID", all:"Todos los estados", suspicious:"Solo sospechosos", load:"Actualizar", loading:"Cargando…", empty:"No hay referidos.", err:"No se pudieron cargar.", approve:"Aprobar", reject:"Rechazar", fraud:"Fraude", restore:"Restaurar", referrer:"Referente", referred:"Referido", source:"Origen", attribution:"Atribución", campaign:"Campaña", applied:"Aplicado", convertedAt:"Convertido", awardedAt:"Otorgado", create:"Crear código", owner:"UUID del propietario (opcional)", code:"Código (opcional)", label:"Etiqueta", max:"Máximo", expires:"Caduca", deactivate:"Desactivar", active:"Activo", uses:"usos" },
  fr: { tab:"Parrainages", codes:"Codes de parrainage", search:"Nom ou ID", all:"Tous les statuts", suspicious:"Suspects uniquement", load:"Actualiser", loading:"Chargement…", empty:"Aucun parrainage.", err:"Chargement impossible.", approve:"Approuver", reject:"Rejeter", fraud:"Fraude", restore:"Restaurer", referrer:"Parrain", referred:"Filleul", source:"Source", attribution:"Attribution", campaign:"Campagne", applied:"Appliqué", convertedAt:"Converti", awardedAt:"Attribué", create:"Créer un code", owner:"UUID propriétaire (facultatif)", code:"Code (facultatif)", label:"Libellé", max:"Limite", expires:"Expire", deactivate:"Désactiver", active:"Actif", uses:"util." },
  hy: { tab:"Հրավերներ", codes:"Հրավերի կոդեր", search:"Անուն կամ ID", all:"Բոլոր կարգավիճակները", suspicious:"Միայն կասկածելի", load:"Թարմացնել", loading:"Բեռնվում է…", empty:"Հրավերներ չկան։", err:"Չհաջողվեց բեռնել։", approve:"Հաստատել", reject:"Մերժել", fraud:"Խարդախություն", restore:"Վերականգնել", referrer:"Հրավիրող", referred:"Հրավիրված", source:"Աղբյուր", attribution:"Վերագրում", campaign:"Արշավ", applied:"Կիրառված", convertedAt:"Հաստատված", awardedAt:"Հաշվարկված", create:"Ստեղծել կոդ", owner:"Սեփականատիրոջ UUID (ոչ պարտադիր)", code:"Կոդ (ոչ պարտադիր)", label:"Անվանում", max:"Սահմանաչափ", expires:"Ավարտվում է", deactivate:"Անջատել", active:"Ակտիվ", uses:"օգտ." },
};

const REF_ADMIN_EXTRA_T = {
  en: { filterCampaign:"Campaign", filterCode:"Code", filterSource:"Source", from:"From", to:"To", probabilistic:"Probabilistic", reassign:"Reassign", reassignPrompt:"New referrer UUID" },
  ru: { filterCampaign:"Кампания", filterCode:"Код", filterSource:"Источник", from:"С", to:"До", probabilistic:"Вероятностная", reassign:"Переназначить", reassignPrompt:"UUID нового реферера" },
  uk: { filterCampaign:"Кампанія", filterCode:"Код", filterSource:"Джерело", from:"Від", to:"До", probabilistic:"Імовірнісна", reassign:"Перепризначити", reassignPrompt:"UUID нового реферера" },
  kk: { filterCampaign:"Кампания", filterCode:"Код", filterSource:"Дереккөз", from:"Бастап", to:"Дейін", probabilistic:"Ықтимал", reassign:"Қайта тағайындау", reassignPrompt:"Жаңа реферер UUID" },
  uz: { filterCampaign:"Kampaniya", filterCode:"Kod", filterSource:"Manba", from:"Dan", to:"Gacha", probabilistic:"Ehtimoliy", reassign:"Qayta tayinlash", reassignPrompt:"Yangi referer UUID" },
  es: { filterCampaign:"Campaña", filterCode:"Código", filterSource:"Origen", from:"Desde", to:"Hasta", probabilistic:"Probabilística", reassign:"Reasignar", reassignPrompt:"UUID del nuevo referente" },
  fr: { filterCampaign:"Campagne", filterCode:"Code", filterSource:"Source", from:"Du", to:"Au", probabilistic:"Probabiliste", reassign:"Réattribuer", reassignPrompt:"UUID du nouveau parrain" },
  hy: { filterCampaign:"Արշավ", filterCode:"Կոդ", filterSource:"Աղբյուր", from:"Սկսած", to:"Մինչև", probabilistic:"Հավանական", reassign:"Վերագրել", reassignPrompt:"Նոր հրավիրողի UUID" },
};
const referralAdminText = (locale) => ({
  ...REF_ADMIN_T.en,
  ...(REF_ADMIN_T[locale] || {}),
  ...REF_ADMIN_EXTRA_T.en,
  ...(REF_ADMIN_EXTRA_T[locale] || {}),
});

const AUDIT_T = {
  en: { tab:"Audit", loading:"Loading…", empty:"No audit entries.", err:"Could not load audit.", actor:"Actor", action:"Action", target:"Target", snapshot:"preserved", system:"System", refresh:"Refresh" },
  ru: { tab:"Аудит", loading:"Загрузка…", empty:"Записей аудита нет.", err:"Не удалось загрузить аудит.", actor:"Актор", action:"Действие", target:"Объект", snapshot:"сохранён", system:"Система", refresh:"Обновить" },
  uk: { tab:"Аудит", loading:"Завантаження…", empty:"Записів аудиту немає.", err:"Не вдалося завантажити аудит.", actor:"Актор", action:"Дія", target:"Об'єкт", snapshot:"збережено", system:"Система", refresh:"Оновити" },
  kk: { tab:"Аудит", loading:"Жүктелуде…", empty:"Аудит жазбалары жоқ.", err:"Аудит жүктелмеді.", actor:"Актор", action:"Әрекет", target:"Нысан", snapshot:"сақталған", system:"Жүйе", refresh:"Жаңарту" },
  uz: { tab:"Audit", loading:"Yuklanmoqda…", empty:"Audit yozuvlari yo'q.", err:"Audit yuklanmadi.", actor:"Aktor", action:"Amal", target:"Obyekt", snapshot:"saqlangan", system:"Tizim", refresh:"Yangilash" },
  es: { tab:"Auditoría", loading:"Cargando…", empty:"No hay registros.", err:"No se pudo cargar.", actor:"Actor", action:"Acción", target:"Objetivo", snapshot:"conservado", system:"Sistema", refresh:"Actualizar" },
  fr: { tab:"Audit", loading:"Chargement…", empty:"Aucune entrée.", err:"Chargement impossible.", actor:"Acteur", action:"Action", target:"Cible", snapshot:"conservé", system:"Système", refresh:"Actualiser" },
  hy: { tab:"Աուդիտ", loading:"Բեռնվում է…", empty:"Գրառումներ չկան։", err:"Չհաջողվեց բեռնել։", actor:"Գործող", action:"Գործողություն", target:"Օբյեկտ", snapshot:"պահպանված", system:"Համակարգ", refresh:"Թարմացնել" },
};

function ReferralAdmin({ api, locale, reviewOnly = false, canReassign = false }) {
  const t = referralAdminText(locale);
  const [items, setItems] = useState_app([]);
  const [filters, setFilters] = useState_app({ status:"", search:"", suspicious:"", campaign:"", code:"", source:"", from:"", to:"" });
  const [loading, setLoading] = useState_app(true);
  const [error, setError] = useState_app(false);
  const [busy, setBusy] = useState_app({});
  const load = async () => {
    setLoading(true); setError(false);
    try {
      const queryFilters = { ...filters };
      for (const key of ["from", "to"]) {
        if (!queryFilters[key]) continue;
        const date = new Date(queryFilters[key]);
        queryFilters[key] = isNaN(date.getTime()) ? "" : date.toISOString();
      }
      const r = reviewOnly ? await api.admin.pendingReferrals() : await api.admin.referrals(queryFilters);
      setItems((r && r.referrals) || []);
    }
    catch (e) { setError(true); }
    finally { setLoading(false); }
  };
  useEffect_app(() => { load(); }, []);
  const act = async (item, action) => {
    if (busy[item.id]) return;
    setBusy(b => ({ ...b, [item.id]: true })); setError(false);
    try {
      if (action === "approve") await api.admin.approveReferral(item.id);
      else if (action === "fraud") {
        const reason = window.prompt(t.fraud, ""); if (!reason) return;
        await api.admin.setReferralStatus(item.id, "fraud", reason);
      } else await api.admin.setReferralStatus(item.id, action);
      await load();
    } catch (e) { setError(true); }
    finally { setBusy(b => { const n = { ...b }; delete n[item.id]; return n; }); }
  };
  const reassign = async (item) => {
    if (!canReassign || busy[item.id]) return;
    const value = window.prompt(t.reassignPrompt, item.referrer_id || "");
    const referrerId = value && value.trim();
    if (!referrerId || referrerId === item.referrer_id) return;
    setBusy(b => ({ ...b, [item.id]: true })); setError(false);
    try { await api.admin.reassignReferral(item.id, { referrer_id: referrerId }); await load(); }
    catch (e) { setError(true); }
    finally { setBusy(b => { const n = { ...b }; delete n[item.id]; return n; }); }
  };
  return (
    <div className="ref-admin">
      <div className="ref-admin-filters">
        {!reviewOnly ? <React.Fragment>
        <input className="rm-input" value={filters.search} placeholder={t.search} onChange={e => setFilters(f => ({ ...f, search: e.target.value }))} />
        <select className="rm-input" value={filters.status} onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}>
          <option value="">{t.all}</option>{["pending","converted","awarded","rejected","fraud"].map(s => <option key={s} value={s}>{s}</option>)}
        </select>
        <input className="rm-input" value={filters.campaign} placeholder={t.filterCampaign} onChange={e => setFilters(f => ({ ...f, campaign:e.target.value }))} />
        <input className="rm-input" value={filters.code} placeholder={t.filterCode} onChange={e => setFilters(f => ({ ...f, code:e.target.value }))} />
        <input className="rm-input" value={filters.source} placeholder={t.filterSource} onChange={e => setFilters(f => ({ ...f, source:e.target.value }))} />
        <input className="rm-input" type="datetime-local" aria-label={t.from} value={filters.from} onChange={e => setFilters(f => ({ ...f, from:e.target.value }))} />
        <input className="rm-input" type="datetime-local" aria-label={t.to} value={filters.to} onChange={e => setFilters(f => ({ ...f, to:e.target.value }))} />
        <label className="ref-admin-check"><input type="checkbox" checked={filters.suspicious === "true"} onChange={e => setFilters(f => ({ ...f, suspicious: e.target.checked ? "true" : "" }))} /> {t.suspicious}</label>
        </React.Fragment> : null}
        <button className="la-btn ok" onClick={load}>{t.load}</button>
      </div>
      {error ? <div className="la-msg">{t.err}</div> : loading ? <div className="la-msg">{t.loading}</div>
        : items.length === 0 ? <div className="la-msg">{t.empty}</div>
        : items.map(it => {
          const probabilistic = it.attribution === "probabilistic" || it.source === "probabilistic";
          return (
          <div key={it.id} className={"la-card ref-admin-row" + (it.suspicious ? " suspicious" : "") + (probabilistic ? " probabilistic" : "")}>
            <div className="la-top"><span className="la-topic">{it.status}</span>{it.suspicious ? <span className="la-badge rej">!</span> : null}{probabilistic ? <span className="la-badge pend">{t.probabilistic}</span> : null}<span className="la-date">{String(it.applied_at || "").slice(0,10)}</span></div>
            <div className="ref-admin-grid">
              <span><b>ID</b>{it.id}</span>
              <span><b>{t.referrer}</b>{it.referrer_name || it.referrer_id}</span><span><b>{t.referred}</b>{it.referred_name || it.referred_id}</span>
              <span><b>Code</b>{it.code || "—"}</span><span><b>{t.campaign}</b>{it.campaign || "—"}</span><span><b>{t.source}</b>{it.source || "—"}</span>
              <span><b>{t.attribution || "Attribution"}</b>{it.attribution || "—"}</span><span><b>{t.applied || "Applied"}</b>{String(it.applied_at || "—").slice(0,19)}</span>
              <span><b>{t.convertedAt || "Converted"}</b>{String(it.converted_at || "—").slice(0,19)}</span><span><b>{t.awardedAt || "Awarded"}</b>{String(it.awarded_at || "—").slice(0,19)}</span>
            </div>
            <div className="la-foot"><span className="la-act">
              {it.status === "pending" ? <button className="la-btn ok" disabled={busy[it.id]} onClick={() => act(it,"approve")}>{t.approve}</button> : null}
              {!reviewOnly && (it.status !== "rejected" ? <button className="la-btn no" disabled={busy[it.id]} onClick={() => act(it,"rejected")}>{t.reject}</button> : <button className="la-btn ok" disabled={busy[it.id]} onClick={() => act(it,"pending")}>{t.restore}</button>)}
              {!reviewOnly && (it.status !== "fraud" ? <button className="la-btn no" disabled={busy[it.id]} onClick={() => act(it,"fraud")}>{t.fraud}</button> : <button className="la-btn ok" disabled={busy[it.id]} onClick={() => act(it,"pending")}>{t.restore}</button>)}
              {canReassign ? <button className="la-btn" disabled={busy[it.id]} onClick={() => reassign(it)}>{t.reassign}</button> : null}
            </span></div>
          </div>
        );})}
    </div>
  );
}

function ReferralCodesAdmin({ api, locale }) {
  const t = referralAdminText(locale);
  const [codes, setCodes] = useState_app([]);
  const [form, setForm] = useState_app({ code:"", user_id:"", kind:"campaign", label:"", campaign:"", max_uses:"", expires_at:"" });
  const [state, setState] = useState_app("loading");
  const load = async () => { setState("loading"); try { const r = await api.admin.referralCodes(); setCodes((r && r.codes) || []); setState("ready"); } catch (e) { setState("error"); } };
  useEffect_app(() => { load(); }, []);
  const create = async () => {
    setState("loading");
    try {
      await api.admin.createReferralCode({
        kind: form.kind, ...(form.code ? { code:form.code } : {}), ...(form.user_id ? { user_id:form.user_id } : {}),
        ...(form.label ? { label:form.label } : {}), ...(form.campaign ? { campaign:form.campaign } : {}),
        ...(form.max_uses ? { max_uses:Number(form.max_uses) } : {}), ...(form.expires_at ? { expires_at:new Date(form.expires_at).toISOString() } : {}),
      });
      setForm({ code:"", user_id:"", kind:"campaign", label:"", campaign:"", max_uses:"", expires_at:"" }); await load();
    } catch (e) { setState("error"); }
  };
  return (
    <div className="ref-admin">
      <div className="ref-code-form">
        <input className="rm-input" placeholder={t.code} value={form.code} onChange={e => setForm(f => ({...f,code:e.target.value}))} />
        <input className="rm-input" placeholder={t.owner} value={form.user_id} onChange={e => setForm(f => ({...f,user_id:e.target.value}))} />
        <select className="rm-input" value={form.kind} onChange={e => setForm(f => ({...f,kind:e.target.value}))}><option value="campaign">campaign</option><option value="custom">custom</option></select>
        <input className="rm-input" placeholder={t.label} value={form.label} onChange={e => setForm(f => ({...f,label:e.target.value}))} />
        <input className="rm-input" placeholder={t.campaign} value={form.campaign} onChange={e => setForm(f => ({...f,campaign:e.target.value}))} />
        <input className="rm-input" type="number" min="1" placeholder={t.max} value={form.max_uses} onChange={e => setForm(f => ({...f,max_uses:e.target.value}))} />
        <input className="rm-input" type="datetime-local" aria-label={t.expires} value={form.expires_at} onChange={e => setForm(f => ({...f,expires_at:e.target.value}))} />
        <button className="la-btn ok" onClick={create}>{t.create}</button>
      </div>
      {state === "error" ? <div className="la-msg">{t.err}</div> : state === "loading" ? <div className="la-msg">{t.loading}</div>
        : codes.map(c => <div key={c.code} className="la-card ref-code-row"><div><b>{c.code}</b><div className="acc-sub">{c.label || c.campaign || c.kind} · {c.uses} {t.uses}</div></div><span className={"la-badge " + (c.active ? "pub" : "rej")}>{c.active ? t.active : t.deactivate}</span><button className={"la-btn " + (c.active ? "no" : "ok")} onClick={async () => { await api.admin.updateReferralCode(c.code,{active:!c.active}); await load(); }}>{c.active ? t.deactivate : t.restore}</button></div>)}
    </div>
  );
}

function AuditAdmin({ api, locale }) {
  const t = AUDIT_T[locale] || AUDIT_T.en;
  const [entries, setEntries] = useState_app([]);
  const [state, setState] = useState_app("loading");
  const load = async () => {
    setState("loading");
    try { const r = await api.admin.audit(200); setEntries((r && r.entries) || []); setState("ready"); }
    catch (e) { setState("error"); }
  };
  useEffect_app(() => { load(); }, []);
  return (
    <div className="audit-admin">
      <div className="audit-toolbar"><button className="la-btn ok" onClick={load}>{t.refresh}</button></div>
      {state === "error" ? <div className="la-msg">{t.err}</div> : state === "loading" ? <div className="la-msg">{t.loading}</div>
        : entries.length === 0 ? <div className="la-msg">{t.empty}</div>
        : entries.map(entry => {
          const actor = entry.actor_effective_id || entry.actor_id || entry.actor_id_snapshot;
          const preserved = !entry.actor_id && !!entry.actor_id_snapshot;
          return <div key={entry.id} className="la-card audit-row">
            <div className="la-top"><span className="la-topic">{entry.action}</span><span className="la-date">{String(entry.created_at || "").slice(0,19)}</span></div>
            <div className="ref-admin-grid">
              <span><b>{t.actor}</b>{actor || t.system}{preserved ? <small className="audit-preserved"> · {t.snapshot}</small> : null}</span>
              <span><b>{t.target}</b>{entry.target || "—"}</span>
              <span><b>ID</b>{entry.id}</span>
            </div>
            {entry.meta ? <pre className="audit-meta">{JSON.stringify(entry.meta, null, 2)}</pre> : null}
          </div>;
        })}
    </div>
  );
}

// D1: localized labels for the truthful identity kind + verification state the server sends
// (kind ∈ evm_wallet|ton_wallet|telegram|email|other, verified_state ∈ verified|unverified|
// manual|n/a). EN is the fallback for any locale/key that's missing.
const IDENTITY_KIND_T = {
  en: { evm_wallet:"EVM wallet", ton_wallet:"TON wallet", telegram:"Telegram", email:"Email", other:"Identity" },
  ru: { evm_wallet:"EVM-кошелёк", ton_wallet:"TON-кошелёк", telegram:"Telegram", email:"Email", other:"Идентификатор" },
  uk: { evm_wallet:"EVM-гаманець", ton_wallet:"TON-гаманець", telegram:"Telegram", email:"Email", other:"Ідентифікатор" },
  kk: { evm_wallet:"EVM әмиян", ton_wallet:"TON әмиян", telegram:"Telegram", email:"Email", other:"Сәйкестендіргіш" },
  uz: { evm_wallet:"EVM hamyon", ton_wallet:"TON hamyon", telegram:"Telegram", email:"Email", other:"Identifikator" },
  es: { evm_wallet:"Billetera EVM", ton_wallet:"Billetera TON", telegram:"Telegram", email:"Correo", other:"Identidad" },
  fr: { evm_wallet:"Portefeuille EVM", ton_wallet:"Portefeuille TON", telegram:"Telegram", email:"E-mail", other:"Identité" },
  hy: { evm_wallet:"EVM դրամապանակ", ton_wallet:"TON դրամապանակ", telegram:"Telegram", email:"Էլ. փոստ", other:"Նույնացուցիչ" },
};
const IDENTITY_STATE_T = {
  en: { verified:"verified", unverified:"unverified", manual:"manual" },
  ru: { verified:"подтверждён", unverified:"не подтверждён", manual:"вручную" },
  uk: { verified:"підтверджено", unverified:"не підтверджено", manual:"вручну" },
  kk: { verified:"расталған", unverified:"расталмаған", manual:"қолмен" },
  uz: { verified:"tasdiqlangan", unverified:"tasdiqlanmagan", manual:"qo‘lda" },
  es: { verified:"verificado", unverified:"no verificado", manual:"manual" },
  fr: { verified:"vérifié", unverified:"non vérifié", manual:"manuel" },
  hy: { verified:"հաստատված", unverified:"չհաստատված", manual:"ձեռքով" },
};
function identityLabelText(locale, kind, state) {
  const k = (IDENTITY_KIND_T[locale] || IDENTITY_KIND_T.en);
  const s = (IDENTITY_STATE_T[locale] || IDENTITY_STATE_T.en);
  const base = k[kind] || (IDENTITY_KIND_T.en[kind]) || kind || "";
  if (state && state !== "n/a") return `${base} · ${s[state] || IDENTITY_STATE_T.en[state] || state}`;
  return base;
}

const USERS_ADMIN_T = {
  ru: {
    tab:"Пользователи", sub:"Журнал пользователей и досье", search:"Имя или UUID", find:"Найти",
    loading:"Загрузка…", empty:"Пользователи не найдены.", err:"Не удалось загрузить пользователей.",
    registered:"Регистрация", activity:"Активность", points:"Баллы", plan:"План", dossier:"Открыть досье",
    newer:"Назад", older:"Дальше", profile:"Профиль", identities:"Идентификаторы", learning:"Обучение",
    insights:"Инсайты", referrals:"Рефералы", sessions:"Сессии", activeTime:"Активное время",
    activeDays:"Активных дней", topics:"Тем начато", completed:"Тем завершено", missions:"Миссий",
    quizzes:"Квизов верно", reveal:"Раскрыть PII", hide:"Скрыть PII", close:"Закрыть", noData:"Нет данных", masked:"скрыт",
  },
  en: {
    tab:"Users", sub:"User journal and dossiers", search:"Name or UUID", find:"Search",
    loading:"Loading…", empty:"No users found.", err:"Could not load users.", registered:"Registered",
    activity:"Activity", points:"Points", plan:"Plan", dossier:"Open dossier", newer:"Previous", older:"Next",
    profile:"Profile", identities:"Identities", learning:"Learning", insights:"Insights", referrals:"Referrals",
    sessions:"Sessions", activeTime:"Active time", activeDays:"Active days", topics:"Topics started",
    completed:"Topics completed", missions:"Missions", quizzes:"Correct quizzes", reveal:"Reveal PII",
    hide:"Mask PII", close:"Close", noData:"No data", masked:"masked",
  },
};

function UsersAdmin({ api, locale }) {
  const t = USERS_ADMIN_T[locale] || USERS_ADMIN_T.en;
  const PAGE = 25;
  const [q, setQ] = useState_app("");
  const [sort, setSort] = useState_app("created_desc");
  const [offset, setOffset] = useState_app(0);
  const [users, setUsers] = useState_app([]);
  const [total, setTotal] = useState_app(0);
  const [state, setState] = useState_app("loading");
  const [dossier, setDossier] = useState_app(null);
  const [dossierState, setDossierState] = useState_app("idle");

  const fmt = (value) => {
    if (!value) return "—";
    try { return new Date(value).toLocaleString(locale === "en" ? "en-GB" : locale, { dateStyle:"medium", timeStyle:"short" }); }
    catch (e) { return String(value).slice(0, 19); }
  };
  const duration = (seconds) => {
    const n = Number(seconds || 0);
    return `${Math.floor(n / 3600)}h ${Math.floor((n % 3600) / 60)}m`;
  };
  const load = async (nextOffset = offset) => {
    setState("loading");
    try {
      const r = await api.admin.usersSearch(q.trim(), PAGE, nextOffset, sort);
      setUsers((r && r.users) || []); setTotal((r && r.total) || 0); setOffset(nextOffset); setState("ready");
    } catch (e) { setState("error"); }
  };
  useEffect_app(() => { load(0); }, [sort]);

  const openDossier = async (id, reveal = false) => {
    setDossierState("loading");
    try { setDossier(await api.admin.userDossier(id, reveal)); setDossierState("ready"); }
    catch (e) { setDossierState("error"); }
  };
  const profile = dossier && dossier.profile;
  return (
    <div className="users-admin">
      <div className="users-toolbar">
        <input className="rm-input" value={q} placeholder={t.search} onChange={e => setQ(e.target.value)} onKeyDown={e => { if (e.key === "Enter") load(0); }} />
        <select className="rm-input users-sort" value={sort} onChange={e => setSort(e.target.value)}>
          <option value="created_desc">{t.registered}</option><option value="activity_desc">{t.activity}</option>
          <option value="points_desc">{t.points}</option><option value="name_asc">A–Z</option>
        </select>
        <button className="la-btn ok" onClick={() => load(0)}>{t.find}</button>
      </div>
      {state === "loading" ? <div className="la-msg">{t.loading}</div> : state === "error" ? <div className="la-msg">{t.err}</div>
        : users.length === 0 ? <div className="la-msg">{t.empty}</div>
        : <div className="users-table-wrap"><table className="users-table"><thead><tr><th>{t.profile}</th><th>{t.registered}</th><th>{t.activity}</th><th>{t.points}</th><th>{t.plan}</th><th></th></tr></thead><tbody>
          {users.map(u => <tr key={u.id}><td><b>{u.display_name || "—"}</b><small>{u.id}<br />{(u.roles || []).join(", ") || "user"}</small></td><td>{fmt(u.created_at)}</td><td>{fmt(u.last_seen)}</td><td>{u.points}</td><td>{u.plan}</td><td><button className="la-btn ok" onClick={() => openDossier(u.id)}>{t.dossier}</button></td></tr>)}
        </tbody></table></div>}
      <div className="users-pages"><button className="la-btn" disabled={offset === 0} onClick={() => load(Math.max(0, offset - PAGE))}>{t.newer}</button><span>{Math.min(offset + 1, total)}–{Math.min(offset + PAGE, total)} / {total}</span><button className="la-btn" disabled={offset + PAGE >= total} onClick={() => load(offset + PAGE)}>{t.older}</button></div>

      {dossierState !== "idle" ? <div className="dossier-backdrop" role="dialog" aria-modal="true" onMouseDown={e => { if (e.target === e.currentTarget) { setDossier(null); setDossierState("idle"); } }}>
        <div className="dossier-panel">
          {dossierState === "loading" ? <div className="la-msg">{t.loading}</div> : dossierState === "error" ? <div className="la-msg">{t.err}</div> : profile ? <React.Fragment>
            <div className="dossier-head"><div><h3>{profile.display_name || profile.id}</h3><small>{profile.id}</small></div><div className="dossier-actions"><button className="la-btn ok" onClick={() => openDossier(profile.id, !dossier.pii_revealed)}>{dossier.pii_revealed ? t.hide : t.reveal}</button><button className="la-btn" onClick={() => { setDossier(null); setDossierState("idle"); }}>{t.close}</button></div></div>
            <div className="dossier-grid">
              <section><h4>{t.profile}</h4><dl><dt>{t.registered}</dt><dd>{fmt(profile.created_at)}</dd><dt>{t.plan}</dt><dd>{profile.plan}{profile.plan_until ? ` · ${fmt(profile.plan_until)}` : ""}</dd><dt>Locale</dt><dd>{profile.locale}</dd><dt>Status</dt><dd>{profile.status} · {profile.activated_at ? "activated" : "pending"}</dd><dt>Roles</dt><dd>{(profile.roles || []).join(", ")}</dd></dl></section>
              <section><h4>{t.activity}</h4><dl><dt>{t.activeTime}</dt><dd>{duration(dossier.activity.active_seconds)}</dd><dt>{t.activeDays}</dt><dd>{dossier.activity.active_days}</dd><dt>Last</dt><dd>{fmt(dossier.activity.last_active_day)}</dd></dl></section>
              <section><h4>{t.learning}</h4><dl><dt>{t.topics}</dt><dd>{dossier.learning.topics_started}</dd><dt>{t.completed}</dt><dd>{dossier.learning.topics_completed}</dd><dt>{t.missions}</dt><dd>{dossier.learning.missions_done}</dd><dt>{t.quizzes}</dt><dd>{dossier.learning.quizzes_correct}</dd></dl></section>
              <section><h4>{t.identities}</h4>{dossier.identities.length ? dossier.identities.map((x,i) => <div className="dossier-line" key={i}><b>{identityLabelText(locale, x.kind, x.verified_state)}</b><span>{x.external_id}</span><small>{x.masked ? t.masked : ""}</small></div>) : <p>{t.noData}</p>}</section>
              <section><h4>{t.points} · {dossier.points.balance}</h4>{dossier.points.items.slice(0,20).map(x => <div className="dossier-line" key={x.id}><b>{x.delta > 0 ? `+${x.delta}` : x.delta}</b><span>{x.reason}</span><small>{fmt(x.created_at)}</small></div>)}</section>
              <section><h4>{t.insights} · {dossier.insights.total}</h4>{dossier.insights.items.slice(0,10).map(x => <div className="dossier-line" key={x.id}><b>{x.topic || "—"}</b><span>{String(x.body || "").slice(0,120)}</span><small>{x.status}</small></div>)}</section>
              <section><h4>{t.referrals} · {dossier.referrals.activated}/{dossier.referrals.total}</h4>{dossier.referrals.items.slice(0,20).map(x => <div className="dossier-line" key={x.id}><b>{x.referred_name || x.referred_id}</b><span>{x.status}</span><small>{fmt(x.created_at)}</small></div>)}</section>
              <section><h4>{t.sessions}</h4>{dossier.sessions.map(x => <div className="dossier-line" key={x.id}><b>{x.revoked_at ? "revoked" : "active"}</b><span>{x.device}</span><small>{fmt(x.last_used_at)}</small></div>)}</section>
            </div>
          </React.Fragment> : null}
        </div>
      </div> : null}
    </div>
  );
}

// ── ATTRIBUTION (#60): superadmin-only Attribution / Атрибуция tab. The server (403) is the real
// security boundary; this UI is rendered only for a superadmin. All strings are localized for the
// 8 project locales — no silent English fallback in an otherwise localized UI. ───────────────────
const ATTR_TX = {
  ru: { tab:"Атрибуция", title:"Атрибуция", sub:"Ссылки, каналы, материалы, издатели, гео и воронка", load:"Обновить", loading:"Загрузка…", empty:"Нет данных за период.", error:"Не удалось загрузить аналитику.", unavailable:"Недоступно", partial:"Частичное покрытие", retry:"Повторить", apply:"Применить", reset:"Сбросить", presetToday:"Сегодня", preset7:"7 дней", preset30:"30 дней", preset90:"90 дней", presetCustom:"Период", from:"С", to:"По", fChannel:"Канал", fMaterial:"Материал", fCreator:"Автор (UUID)", fPlacement:"Плейсмент", all:"Все", sLinks:"Ссылок создано", sCards:"Превью-фетчи", sHuman:"Открытий (люди)", sUnique:"Уникальных", sReg:"Регистраций", sAct:"Активаций", sConv:"Конверсия", funnel:"Воронка", secChannels:"Каналы", secMaterials:"Материалы", secPublishers:"Издатели", secGeo:"География", secLinks:"Ссылки", thCode:"Код", thChannel:"Канал", thMaterial:"Материал", thPlacement:"Плейсмент", thCreated:"Создана", thOpens:"Открытий", thUnique:"Уник.", thCreator:"Автор", thResult:"Результат", sortCreated:"по дате", sortOpens:"по открытиям", sortUnique:"по уник.", prev:"Назад", next:"Вперёд", other:"Прочее/скрыто", noteSuppress:"Группы гео менее 5 уникальных посетителей скрыты без меток.", noteHuman:"«Люди» — не распознанные превью-боты, нижняя оценка, не проверенные люди.", noteUnique:"Уникальные — дневные HMAC-псевдонимы, оценка приватности, не кросс-устройство.", notePlacement:"Плейсмент известен только если автор его пометил вручную.", noteCoverage:"Данные начинаются с миграции 019; историю до сбора восстановить нельзя." },
  en: { tab:"Attribution", title:"Attribution", sub:"Links, channels, materials, publishers, geography and funnel", load:"Refresh", loading:"Loading…", empty:"No data for this period.", error:"Could not load analytics.", unavailable:"Unavailable", partial:"Partial coverage", retry:"Retry", apply:"Apply", reset:"Reset", presetToday:"Today", preset7:"7 days", preset30:"30 days", preset90:"90 days", presetCustom:"Custom", from:"From", to:"To", fChannel:"Channel", fMaterial:"Material", fCreator:"Creator (UUID)", fPlacement:"Placement", all:"All", sLinks:"Links created", sCards:"Card fetches", sHuman:"Human opens", sUnique:"Unique opens", sReg:"Registrations", sAct:"Activations", sConv:"Conversion", funnel:"Funnel", secChannels:"Channels", secMaterials:"Materials", secPublishers:"Publishers", secGeo:"Geography", secLinks:"Links", thCode:"Code", thChannel:"Channel", thMaterial:"Material", thPlacement:"Placement", thCreated:"Created", thOpens:"Opens", thUnique:"Uniq.", thCreator:"Creator", thResult:"Result", sortCreated:"by date", sortOpens:"by opens", sortUnique:"by unique", prev:"Prev", next:"Next", other:"Other/suppressed", noteSuppress:"Geography groups with fewer than 5 unique visitors are hidden with no label.", noteHuman:"“Human” means not a known preview bot — a lower bound, not verified people.", noteUnique:"Unique opens are daily HMAC pseudonyms: a privacy estimate, not cross-device identity.", notePlacement:"Placement is known only when the creator tagged it manually.", noteCoverage:"Data begins at migration 019; history before collection cannot be reconstructed." },
  uk: { tab:"Атрибуція", title:"Атрибуція", sub:"Посилання, канали, матеріали, видавці, гео та воронка", load:"Оновити", loading:"Завантаження…", empty:"Немає даних за період.", error:"Не вдалося завантажити аналітику.", unavailable:"Недоступно", partial:"Часткове покриття", retry:"Повторити", apply:"Застосувати", reset:"Скинути", presetToday:"Сьогодні", preset7:"7 днів", preset30:"30 днів", preset90:"90 днів", presetCustom:"Період", from:"З", to:"По", fChannel:"Канал", fMaterial:"Матеріал", fCreator:"Автор (UUID)", fPlacement:"Плейсмент", all:"Усі", sLinks:"Створено посилань", sCards:"Превʼю-фетчі", sHuman:"Відкриттів (люди)", sUnique:"Унікальних", sReg:"Реєстрацій", sAct:"Активацій", sConv:"Конверсія", funnel:"Воронка", secChannels:"Канали", secMaterials:"Матеріали", secPublishers:"Видавці", secGeo:"Географія", secLinks:"Посилання", thCode:"Код", thChannel:"Канал", thMaterial:"Матеріал", thPlacement:"Плейсмент", thCreated:"Створено", thOpens:"Відкриттів", thUnique:"Унік.", thCreator:"Автор", thResult:"Результат", sortCreated:"за датою", sortOpens:"за відкриттями", sortUnique:"за унік.", prev:"Назад", next:"Далі", other:"Інше/приховано", noteSuppress:"Групи гео з менш ніж 5 унікальними відвідувачами приховані без міток.", noteHuman:"«Люди» — не розпізнані превʼю-боти, нижня оцінка, не перевірені люди.", noteUnique:"Унікальні — денні HMAC-псевдоніми, оцінка приватності, не кросплатформна.", notePlacement:"Плейсмент відомий лише якщо автор позначив його вручну.", noteCoverage:"Дані починаються з міграції 019; історію до збору відновити неможливо." },
  kk: { tab:"Атрибуция", title:"Атрибуция", sub:"Сілтемелер, арналар, материалдар, жариялаушылар, гео және шұңғыма", load:"Жаңарту", loading:"Жүктелуде…", empty:"Кезеңде дерек жоқ.", error:"Аналитиканы жүктеу мүмкін болмады.", unavailable:"Қолжетімсіз", partial:"Ішінара қамту", retry:"Қайталау", apply:"Қолдану", reset:"Тазалау", presetToday:"Бүгін", preset7:"7 күн", preset30:"30 күн", preset90:"90 күн", presetCustom:"Кезең", from:"Бастап", to:"Дейін", fChannel:"Арна", fMaterial:"Материал", fCreator:"Автор (UUID)", fPlacement:"Плейсмент", all:"Барлығы", sLinks:"Сілтеме жасалды", sCards:"Превью-фетч", sHuman:"Ашылымдар (адам)", sUnique:"Бірегей", sReg:"Тіркелулер", sAct:"Белсендірулер", sConv:"Конверсия", funnel:"Шұңғыма", secChannels:"Арналар", secMaterials:"Материалдар", secPublishers:"Жариялаушылар", secGeo:"География", secLinks:"Сілтемелер", thCode:"Код", thChannel:"Арна", thMaterial:"Материал", thPlacement:"Плейсмент", thCreated:"Жасалды", thOpens:"Ашылым", thUnique:"Бірег.", thCreator:"Автор", thResult:"Нәтиже", sortCreated:"күні бойынша", sortOpens:"ашылым бойынша", sortUnique:"бірегей бойынша", prev:"Артқа", next:"Алға", other:"Басқа/жасырылған", noteSuppress:"5-тен аз бірегей келушісі бар гео топтары белгісіз жасырылады.", noteHuman:"«Адам» — танылмаған превью-бот емес, төменгі баға, тексерілген адам емес.", noteUnique:"Бірегей — күндік HMAC-бүркеншік, құпиялылық бағасы, кросс-құрылғы емес.", notePlacement:"Плейсмент автор қолмен белгілегенде ғана белгілі.", noteCoverage:"Деректер 019 көшуден басталады; жинауға дейінгі тарихты қалпына келтіру мүмкін емес." },
  uz: { tab:"Atribusiya", title:"Atribusiya", sub:"Havolalar, kanallar, materiallar, nashrchilar, geo va voronka", load:"Yangilash", loading:"Yuklanmoqda…", empty:"Ushbu davr uchun maʼlumot yoʻq.", error:"Analitikani yuklab boʻlmadi.", unavailable:"Mavjud emas", partial:"Qisman qamrov", retry:"Qayta urinish", apply:"Qoʻllash", reset:"Tozalash", presetToday:"Bugun", preset7:"7 kun", preset30:"30 kun", preset90:"90 kun", presetCustom:"Davr", from:"Dan", to:"Gacha", fChannel:"Kanal", fMaterial:"Material", fCreator:"Muallif (UUID)", fPlacement:"Joylashuv", all:"Barchasi", sLinks:"Havola yaratildi", sCards:"Prevʼyu-fetch", sHuman:"Ochilishlar (odam)", sUnique:"Noyob", sReg:"Roʻyxatdan oʻtishlar", sAct:"Faollashtirishlar", sConv:"Konversiya", funnel:"Voronka", secChannels:"Kanallar", secMaterials:"Materiallar", secPublishers:"Nashrchilar", secGeo:"Geografiya", secLinks:"Havolalar", thCode:"Kod", thChannel:"Kanal", thMaterial:"Material", thPlacement:"Joylashuv", thCreated:"Yaratilgan", thOpens:"Ochilish", thUnique:"Noyob", thCreator:"Muallif", thResult:"Natija", sortCreated:"sana boʻyicha", sortOpens:"ochilish boʻyicha", sortUnique:"noyob boʻyicha", prev:"Orqaga", next:"Oldinga", other:"Boshqa/yashirilgan", noteSuppress:"5 tadan kam noyob tashrifchili geo guruhlar belgisiz yashiriladi.", noteHuman:"«Odam» — tanilmagan prevʼyu-bot emas, quyi baho, tasdiqlangan odam emas.", noteUnique:"Noyob — kunlik HMAC-taxalluslar, maxfiylik bahosi, qurilmalararo emas.", notePlacement:"Joylashuv faqat muallif qoʻlda belgilaganda maʼlum.", noteCoverage:"Maʼlumot 019 migratsiyadan boshlanadi; yigʻishdan oldingi tarixni tiklab boʻlmaydi." },
  es: { tab:"Atribución", title:"Atribución", sub:"Enlaces, canales, materiales, editores, geografía y embudo", load:"Actualizar", loading:"Cargando…", empty:"No hay datos para este periodo.", error:"No se pudo cargar la analítica.", unavailable:"No disponible", partial:"Cobertura parcial", retry:"Reintentar", apply:"Aplicar", reset:"Restablecer", presetToday:"Hoy", preset7:"7 días", preset30:"30 días", preset90:"90 días", presetCustom:"Personalizado", from:"Desde", to:"Hasta", fChannel:"Canal", fMaterial:"Material", fCreator:"Autor (UUID)", fPlacement:"Ubicación", all:"Todos", sLinks:"Enlaces creados", sCards:"Fetches de vista previa", sHuman:"Aperturas (humanas)", sUnique:"Únicas", sReg:"Registros", sAct:"Activaciones", sConv:"Conversión", funnel:"Embudo", secChannels:"Canales", secMaterials:"Materiales", secPublishers:"Editores", secGeo:"Geografía", secLinks:"Enlaces", thCode:"Código", thChannel:"Canal", thMaterial:"Material", thPlacement:"Ubicación", thCreated:"Creado", thOpens:"Aperturas", thUnique:"Únic.", thCreator:"Autor", thResult:"Resultado", sortCreated:"por fecha", sortOpens:"por aperturas", sortUnique:"por únicas", prev:"Anterior", next:"Siguiente", other:"Otros/ocultos", noteSuppress:"Los grupos geográficos con menos de 5 visitantes únicos se ocultan sin etiqueta.", noteHuman:"«Humano» significa no reconocido como bot de vista previa: un mínimo, no personas verificadas.", noteUnique:"Únicas son seudónimos HMAC diarios: una estimación de privacidad, no identidad entre dispositivos.", notePlacement:"La ubicación solo se conoce si el autor la etiquetó manualmente.", noteCoverage:"Los datos comienzan en la migración 019; el historial anterior no puede reconstruirse." },
  fr: { tab:"Attribution", title:"Attribution", sub:"Liens, canaux, matériaux, éditeurs, géographie et entonnoir", load:"Actualiser", loading:"Chargement…", empty:"Aucune donnée pour cette période.", error:"Impossible de charger les analyses.", unavailable:"Indisponible", partial:"Couverture partielle", retry:"Réessayer", apply:"Appliquer", reset:"Réinitialiser", presetToday:"Aujourd'hui", preset7:"7 jours", preset30:"30 jours", preset90:"90 jours", presetCustom:"Personnalisé", from:"Du", to:"Au", fChannel:"Canal", fMaterial:"Matériau", fCreator:"Auteur (UUID)", fPlacement:"Emplacement", all:"Tous", sLinks:"Liens créés", sCards:"Fetches d'aperçu", sHuman:"Ouvertures (humaines)", sUnique:"Uniques", sReg:"Inscriptions", sAct:"Activations", sConv:"Conversion", funnel:"Entonnoir", secChannels:"Canaux", secMaterials:"Matériaux", secPublishers:"Éditeurs", secGeo:"Géographie", secLinks:"Liens", thCode:"Code", thChannel:"Canal", thMaterial:"Matériau", thPlacement:"Emplacement", thCreated:"Créé", thOpens:"Ouvertures", thUnique:"Uniq.", thCreator:"Auteur", thResult:"Résultat", sortCreated:"par date", sortOpens:"par ouvertures", sortUnique:"par uniques", prev:"Précédent", next:"Suivant", other:"Autre/masqué", noteSuppress:"Les groupes géographiques de moins de 5 visiteurs uniques sont masqués sans étiquette.", noteHuman:"« Humain » signifie non reconnu comme robot d'aperçu : une borne basse, pas des personnes vérifiées.", noteUnique:"Les uniques sont des pseudonymes HMAC quotidiens : une estimation de confidentialité, pas une identité multi-appareils.", notePlacement:"L'emplacement n'est connu que si l'auteur l'a étiqueté manuellement.", noteCoverage:"Les données commencent à la migration 019 ; l'historique antérieur ne peut être reconstitué." },
  hy: { tab:"Վերագրում", title:"Վերագրում", sub:"Հղումներ, ալիքներ, նյութեր, հրապարակողներ, աշխարհագրություն և ձագար", load:"Թարմացնել", loading:"Բեռնվում է…", empty:"Այս ժամանակահատվածի համար տվյալ չկա։", error:"Չհաջողվեց բեռնել վերլուծությունը։", unavailable:"Անհասանելի", partial:"Մասնակի ընդգրկում", retry:"Կրկնել", apply:"Կիրառել", reset:"Զրոյացնել", presetToday:"Այսօր", preset7:"7 օր", preset30:"30 օր", preset90:"90 օր", presetCustom:"Ժամանակահատված", from:"Սկսած", to:"Մինչև", fChannel:"Ալիք", fMaterial:"Նյութ", fCreator:"Հեղինակ (UUID)", fPlacement:"Տեղադրում", all:"Բոլորը", sLinks:"Ստեղծված հղումներ", sCards:"Նախադիտման ֆեթչեր", sHuman:"Բացումներ (մարդ)", sUnique:"Եզակի", sReg:"Գրանցումներ", sAct:"Ակտիվացումներ", sConv:"Կոնվերսիա", funnel:"Ձագար", secChannels:"Ալիքներ", secMaterials:"Նյութեր", secPublishers:"Հրապարակողներ", secGeo:"Աշխարհագրություն", secLinks:"Հղումներ", thCode:"Կոդ", thChannel:"Ալիք", thMaterial:"Նյութ", thPlacement:"Տեղադրում", thCreated:"Ստեղծված", thOpens:"Բացումներ", thUnique:"Եզակի", thCreator:"Հեղինակ", thResult:"Արդյունք", sortCreated:"ըստ ամսաթվի", sortOpens:"ըստ բացումների", sortUnique:"ըստ եզակիի", prev:"Հետ", next:"Առաջ", other:"Այլ/թաքցված", noteSuppress:"5-ից քիչ եզակի այցելուով աշխարհագրական խմբերը թաքցվում են առանց պիտակի։", noteHuman:"«Մարդ» նշանակում է չճանաչված նախադիտման բոտ՝ ստորին սահման, ոչ ստուգված մարդ։", noteUnique:"Եզակիները օրական HMAC կեղծանուններ են՝ գաղտնիության գնահատական, ոչ կրոս-սարքային ինքնություն։", notePlacement:"Տեղադրումը հայտնի է միայն երբ հեղինակը ձեռքով պիտակել է։", noteCoverage:"Տվյալները սկսվում են 019 միգրացիայից; հավաքագրումից առաջ պատմությունը վերականգնել հնարավոր չէ։" },
};

// Superadmin Attribution tab. Backend enforces 403; this component only renders for a superadmin.
function attrNum(v) {
  if (v === null || v === undefined) return "—";
  if (typeof v === "object") return "—"; // an {available:false} object renders as an em dash
  return String(v);
}
// A metric is "available" unless it is an explicit { available:false } object.
function attrAvail(v) { return !(v && typeof v === "object" && v.available === false); }

function AttributionAdmin({ api, locale }) {
  const t = ATTR_TX[locale] || ATTR_TX.en;
  const [preset, setPreset] = useState_app("30d");
  const [from, setFrom] = useState_app("");   // stored as YYYY-MM-DD (the <input type=date> value)
  const [to, setTo] = useState_app("");       // stored as YYYY-MM-DD
  const [channel, setChannel] = useState_app("");
  const [material, setMaterial] = useState_app("");
  const [creator, setCreator] = useState_app("");
  const [placement, setPlacement] = useState_app("");
  const [sort, setSort] = useState_app("created");
  const [page, setPage] = useState_app(0);
  const [applyTick, setApplyTick] = useState_app(0); // bumped by Apply/Reset to force a fresh query
  const [state, setState] = useState_app("loading"); // loading | ready | error
  const [data, setData] = useState_app(null);

  // Build params from the CURRENT render's state. The effect closure is recreated every render, so
  // Apply/Reset (which bump applyTick) always read the latest values — never stale closure state.
  const buildParams = () => {
    const params = { preset, sort, page };
    if (preset === "custom") {
      // Convert YYYY-MM-DD → full UTC timestamps ONLY when building the request.
      params.from = from + "T00:00:00Z";
      params.to = to + "T23:59:59Z";
    }
    if (channel) params.channel = channel;
    if (material) params.campaign = material;
    if (creator) params.creator = creator;
    if (placement) params.placement = placement;
    return params;
  };
  const load = async () => {
    setState("loading");
    try {
      const r = await api.admin.attribution(buildParams());
      setData(r); setState("ready");
    } catch (_e) { setState("error"); }
  };
  useEffect_app(() => {
    // Selecting `custom` must NOT auto-request an incomplete range (no spurious error) — wait for Apply.
    if (preset === "custom" && (!from || !to)) { setState("ready"); return; }
    load();
    // eslint-disable-next-line
  }, [preset, sort, page, applyTick]);

  // Apply/Reset always query page zero. Bumping applyTick guarantees the effect re-runs even when
  // page was already 0, and the fresh closure reads the latest filter values (no stale state).
  const apply = () => { if (page !== 0) setPage(0); else setApplyTick(x => x + 1); };
  const reset = () => {
    setChannel(""); setMaterial(""); setCreator(""); setPlacement("");
    if (page !== 0) setPage(0); else setApplyTick(x => x + 1);
  };

  const s = (data && data.summary) || {};
  const cov = (data && data.coverage) || {};
  const geo = (data && data.geography) || { rows: [], other_or_suppressed: {} };
  const links = (data && data.links) || { rows: [], has_more: false, page: 0 };
  const conv = (s && s.conversion) || {};
  const presets = [["today", t.presetToday], ["7d", t.preset7], ["30d", t.preset30], ["90d", t.preset90], ["custom", t.presetCustom]];

  return (
    <div className="attr">
      <style>{`
        .attr .attr-bar{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
        .attr .attr-bar button{padding:6px 10px;border-radius:8px;border:1px solid var(--line-soft,#333);background:transparent;color:var(--text-muted,#999);font:600 12px var(--f-sans,sans-serif);cursor:pointer}
        .attr .attr-bar button.on{background:var(--bg-tint,#2a2a3a);color:var(--text,#fff)}
        .attr .attr-filters{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px}
        .attr .attr-filters input,.attr .attr-filters select{min-width:120px;flex:1 1 120px;padding:7px 8px;border-radius:8px;border:1px solid var(--line-soft,#333);background:var(--bg-surface,#1c1c22);color:var(--text,#eee);font:500 12px var(--f-sans,sans-serif)}
        .attr .attr-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-bottom:14px}
        .attr .attr-card{background:var(--bg-surface,#1c1c22);border:1px solid var(--line-soft,#333);border-radius:10px;padding:10px}
        .attr .attr-card b{display:block;font:700 20px var(--f-mono,monospace);color:var(--text,#fff)}
        .attr .attr-card span{font-size:11px;color:var(--text-muted,#999)}
        .attr .attr-sec{margin:14px 0}
        .attr .attr-sec h4{margin:0 0 6px;font:700 13px var(--f-sans,sans-serif);color:var(--text,#eee)}
        .attr .attr-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
        .attr table{width:100%;border-collapse:collapse;font:500 12px var(--f-sans,sans-serif)}
        .attr th,.attr td{padding:6px 8px;text-align:left;border-bottom:1px solid var(--line-soft,#2a2a2a);white-space:nowrap}
        .attr th{color:var(--text-muted,#999);font-weight:700;cursor:pointer}
        .attr .attr-note{font-size:11px;color:var(--text-muted,#888);margin-top:6px;line-height:1.5}
        .attr .attr-msg{padding:24px;text-align:center;color:var(--text-muted,#999)}
      `}</style>

      {/* period presets */}
      <div className="attr-bar" role="tablist" aria-label={t.title}>
        {presets.map(([id, label]) => (
          <button key={id} className={preset === id ? "on" : ""} aria-pressed={preset === id}
            onClick={() => { setPage(0); setPreset(id); }}>{label}</button>
        ))}
        <button onClick={apply}>{t.load}</button>
      </div>

      {preset === "custom" ? (
        <div className="attr-filters">
          <input type="date" aria-label={t.from} value={from} onChange={e => setFrom(e.target.value)} />
          <input type="date" aria-label={t.to} value={to} onChange={e => setTo(e.target.value)} />
          <button onClick={apply} disabled={!from || !to}>{t.apply}</button>
        </div>
      ) : null}

      {/* filters */}
      <div className="attr-filters">
        <select aria-label={t.fChannel} value={channel} onChange={e => setChannel(e.target.value)}>
          <option value="">{t.fChannel}: {t.all}</option>
          <option value="telegram">Telegram</option><option value="facebook">Facebook</option>
          <option value="x">X</option><option value="copy">Copy</option>
        </select>
        <input aria-label={t.fMaterial} placeholder={t.fMaterial} value={material} onChange={e => setMaterial(e.target.value)} />
        <input aria-label={t.fCreator} placeholder={t.fCreator} value={creator} onChange={e => setCreator(e.target.value)} />
        <input aria-label={t.fPlacement} placeholder={t.fPlacement} value={placement} onChange={e => setPlacement(e.target.value)} />
        <button onClick={apply}>{t.apply}</button>
        <button onClick={reset}>{t.reset}</button>
      </div>

      {state === "loading" ? <div className="attr-msg">{t.loading}</div>
        : state === "error" ? <div className="attr-msg">{t.error} <button onClick={apply}>{t.retry}</button></div>
        : (preset === "custom" && (!from || !to)) ? <div className="attr-msg">{t.presetCustom}: {t.from} / {t.to}</div>
        : (!data || (s.human_opens === 0 && s.links_created === 0)) ? <div className="attr-msg">{t.empty}</div>
        : (
        <React.Fragment>
          {/* summary cards */}
          <div className="attr-cards">
            <div className="attr-card"><b>{attrNum(s.links_created)}</b><span>{t.sLinks}</span></div>
            <div className="attr-card"><b>{attrNum(s.human_opens)}</b><span>{t.sHuman}</span></div>
            <div className="attr-card"><b>{s.unique_available ? attrNum(s.unique_opens) : t.unavailable}</b><span>{t.sUnique}</span></div>
            <div className="attr-card"><b>{attrNum(s.card_fetches)}</b><span>{t.sCards}</span></div>
            <div className="attr-card"><b>{attrAvail(s.registrations) ? attrNum(s.registrations) : t.unavailable}</b><span>{t.sReg}</span></div>
            <div className="attr-card"><b>{attrAvail(s.activations) ? attrNum(s.activations) : t.unavailable}</b><span>{t.sAct}</span></div>
            <div className="attr-card"><b>{conv.registrations_per_human_open != null ? conv.registrations_per_human_open + "%" : "—"}</b><span>{t.sConv}</span></div>
          </div>

          {/* funnel */}
          <div className="attr-sec">
            <h4>{t.funnel}</h4>
            <div className="attr-scroll"><table><tbody>
              {(data.funnel && data.funnel.stages || []).map(st => (
                <tr key={st.key}>
                  <td>{st.key}</td>
                  <td><b>{st.available ? attrNum(st.value) : t.unavailable}</b></td>
                </tr>
              ))}
            </tbody></table></div>
          </div>

          {/* channels */}
          <div className="attr-sec">
            <h4>{t.secChannels}</h4>
            <div className="attr-scroll"><table>
              <thead><tr><th>{t.thChannel}</th><th>{t.thOpens}</th><th>{t.thUnique}</th></tr></thead>
              <tbody>{(data.channels || []).map((c, i) => <tr key={i}><td>{c.channel}</td><td>{c.human_opens}</td><td>{c.unique_opens}</td></tr>)}</tbody>
            </table></div>
          </div>

          {/* materials */}
          <div className="attr-sec">
            <h4>{t.secMaterials}</h4>
            <div className="attr-scroll"><table>
              <thead><tr><th>{t.thMaterial}</th><th>{t.thOpens}</th><th>{t.thUnique}</th></tr></thead>
              <tbody>{(data.materials || []).map((m, i) => <tr key={i}><td>{m.material}</td><td>{m.human_opens}</td><td>{m.unique_opens}</td></tr>)}</tbody>
            </table></div>
          </div>

          {/* publishers */}
          <div className="attr-sec">
            <h4>{t.secPublishers}</h4>
            <div className="attr-scroll"><table>
              <thead><tr><th>{t.thCreator}</th><th>{t.sLinks}</th><th>{t.thOpens}</th><th>{t.thUnique}</th><th>{t.thResult}</th></tr></thead>
              <tbody>{(data.publishers || []).map((p, i) => <tr key={i}><td>{p.display_name || p.creator_masked}</td><td>{p.links_created}</td><td>{p.human_opens}</td><td>{p.unique_opens}</td><td>{p.result_available ? attrNum(p.registrations) : t.unavailable}</td></tr>)}</tbody>
            </table></div>
          </div>

          {/* geography */}
          <div className="attr-sec">
            <h4>{t.secGeo}</h4>
            {(geo.available === false) ? <div className="attr-msg">{t.unavailable}</div> : (
              <div className="attr-scroll"><table>
                <thead><tr><th>{t.secGeo}</th><th>{t.thUnique}</th><th>{t.thOpens}</th></tr></thead>
                <tbody>
                  {(geo.rows || []).map((g, i) => <tr key={i}><td>{g.country}{g.region ? " · " + g.region : ""}</td><td>{g.unique_visitors}</td><td>{g.human_opens}</td></tr>)}
                  {geo.other_or_suppressed && geo.other_or_suppressed.groups ? <tr><td>{t.other}</td><td>{geo.other_or_suppressed.unique_visitors}</td><td>{geo.other_or_suppressed.human_opens}</td></tr> : null}
                </tbody>
              </table></div>
            )}
            <div className="attr-note">{t.noteSuppress}</div>
          </div>

          {/* links (sortable + paginated) */}
          <div className="attr-sec">
            <h4>{t.secLinks}</h4>
            <div className="attr-bar">
              {[["created", t.sortCreated], ["opens", t.sortOpens], ["unique", t.sortUnique]].map(([id, label]) => (
                <button key={id} className={sort === id ? "on" : ""} aria-pressed={sort === id} onClick={() => { setPage(0); setSort(id); }}>{label}</button>
              ))}
            </div>
            <div className="attr-scroll"><table>
              <thead><tr><th>{t.thCode}</th><th>{t.thCreator}</th><th>{t.thChannel}</th><th>{t.thMaterial}</th><th>{t.thPlacement}</th><th>{t.thCreated}</th><th>{t.thOpens}</th><th>{t.thUnique}</th><th>{t.thResult}</th></tr></thead>
              <tbody>{(links.rows || []).map((l, i) => (
                <tr key={l.code || i}>
                  <td>{l.code}</td><td>{l.display_name || l.creator_masked || "—"}</td><td>{l.channel}</td><td>{l.material}</td><td>{l.placement || "—"}</td>
                  <td>{l.created_at ? String(l.created_at).slice(0, 10) : "—"}</td><td>{l.human_opens}</td><td>{l.unique_opens}</td>
                  <td>{l.result_available ? attrNum(l.registrations) : t.unavailable}</td>
                </tr>
              ))}</tbody>
            </table></div>
            <div className="attr-bar">
              <button disabled={page <= 0} onClick={() => setPage(Math.max(0, page - 1))}>{t.prev}</button>
              <button disabled={!links.has_more} onClick={() => setPage(page + 1)}>{t.next}</button>
            </div>
          </div>

          <div className="attr-note">
            {t.noteHuman}<br />{t.noteUnique}<br />{t.notePlacement}<br />
            {t.noteCoverage}{cov.coverage_started_at ? " (" + String(cov.coverage_started_at).slice(0, 10) + ")" : ""}
          </div>
        </React.Fragment>
      )}
    </div>
  );
}

function AdminPanel({ locale, role, api, onBack, onChanged }) {
  const tx = ADMIN_T[locale] || ADMIN_T.en;
  // ADMIN-ROLE-MANAGEMENT: superadmins get an extra "Роли · Команда" tab.
  const isSuper = role === "superadmin" || role === 4;
  const isReviewerOnly = role === "moderator" || role === 2;
  const rtx = ROLE_T[locale] || ROLE_T.en;
  const refTx = referralAdminText(locale);
  const auditTx = AUDIT_T[locale] || AUDIT_T.en;
  const usersTx = USERS_ADMIN_T[locale] || USERS_ADMIN_T.en;
  const subTx = ADMIN_SUB_T[locale] || ADMIN_SUB_T.en;
  const attrTx = ATTR_TX[locale] || ATTR_TX.en;
  const tabList = isReviewerOnly ? ["referrals"] : ADMIN_TABS.concat("users", "referrals", "audit", "access", isSuper ? ["referral_codes", "roles", "attribution"] : []);
  const [items, setItems] = useState_app([]);
  const [tab, setTab] = useState_app(isReviewerOnly ? "referrals" : "pending");
  const [loading, setLoading] = useState_app(true);
  const [error, setError] = useState_app(false);
  const [notAdmin, setNotAdmin] = useState_app(false);
  const [busy, setBusy] = useState_app({});

  useEffect_app(() => {
    if (typeof document === "undefined" || document.getElementById("luminara-admin-style")) return;
    const st = document.createElement("style");
    st.id = "luminara-admin-style"; st.textContent = ADMIN_CSS;
    document.head.appendChild(st);
  }, []);

  useEffect_app(() => {
    let alive = true;
    (async () => {
      if (!api) { if (alive) { setLoading(false); setNotAdmin(true); } return; }
      if (isReviewerOnly) { if (alive) setLoading(false); return; }
      setLoading(true); setError(false); setNotAdmin(false);
      try {
        const r = await api.admin.insights();              // { insights[] } — all statuses
        if (alive) setItems((r && r.insights) || []);
      } catch (e) {
        if (!alive) return;
        if (e && (e.isForbidden || e.status === 403)) setNotAdmin(true); else setError(true);
      } finally { if (alive) setLoading(false); }
    })();
    return () => { alive = false; };
  }, []);

  const moderate = async (id, status) => {
    if (!id || busy[id] || !api) return;
    setBusy(b => ({ ...b, [id]: true }));
    try {
      await api.admin.moderate(id, status);                // PATCH /admin/insights/:id { status }
      setItems(list => list.map(x => String(x.id) === String(id) ? { ...x, status } : x));
      if (onChanged) { try { await onChanged(); } catch (e) {} }
    } catch (e) {
      if (e && (e.isForbidden || e.status === 403)) setNotAdmin(true); else setError(true);
    } finally {
      setBusy(b => { const n = { ...b }; delete n[id]; return n; });
    }
  };

  const countOf = (s) => items.filter(x => x.status === s).length;
  const fmtDate = (s) => {
    if (!s) return "";
    const d = new Date(s); if (isNaN(d.getTime())) return "";
    try { return d.toLocaleDateString(locale === "en" ? "en-GB" : locale, { day: "2-digit", month: "short", year: "numeric" }); }
    catch (e) { return String(s).slice(0, 10); }
  };
  const badge = (s) => s === "published" ? <span className="la-badge pub">{tx.published}</span>
    : s === "rejected" ? <span className="la-badge rej">{tx.rejected}</span>
    : <span className="la-badge pend">{tx.pending}</span>;
  const actions = (it) => {
    const d = !!busy[it.id];
    if (it.status === "pending") return (
      <React.Fragment>
        <button className="la-btn ok" disabled={d} onClick={() => moderate(it.id, "published")}>{tx.approve}</button>
        <button className="la-btn no" disabled={d} onClick={() => moderate(it.id, "rejected")}>{tx.reject}</button>
      </React.Fragment>
    );
    if (it.status === "published") return <button className="la-btn no" disabled={d} onClick={() => moderate(it.id, "rejected")}>{tx.hide}</button>;
    return <button className="la-btn ok" disabled={d} onClick={() => moderate(it.id, "published")}>{tx.restore}</button>;
  };

  const rows = items.filter(x => x.status === tab);
  const subtitle = subTx[tab] || tx.sub;
  return (
    <div className="section-pad fade-in">
      <button className="scene-back" onClick={onBack}>← {tx.back}</button>
      <div className="la">
        <div className="la-head">
          <div className="la-title">{tx.title}</div>
          <div className="la-sub">{subtitle}</div>
        </div>
        <div className="la-tabs">
          {tabList.map(id => (
            <button key={id} className={"la-tab" + (tab === id ? " on" : "")} onClick={() => setTab(id)}>
              {id === "roles" ? rtx.tab : id === "users" ? usersTx.tab : id === "referrals" ? refTx.tab : id === "referral_codes" ? refTx.codes : id === "audit" ? auditTx.tab : id === "attribution" ? attrTx.tab : id === "access" ? (ACCESS_ADMIN_T[locale] || ACCESS_ADMIN_T.en).tab : tx[id]}
              {ADMIN_TABS.includes(id) ? <b>{countOf(id)}</b> : null}
            </button>
          ))}
        </div>
        <div className="la-body">
          {tab === "roles" ? <RolesManager api={api} locale={locale} />
            : tab === "users" ? <UsersAdmin api={api} locale={locale} />
            : tab === "access" ? <AccessManager api={api} locale={locale} isSuper={isSuper} />
            : tab === "referrals" ? <ReferralAdmin api={api} locale={locale} reviewOnly={isReviewerOnly} canReassign={isSuper} />
            : tab === "referral_codes" ? <ReferralCodesAdmin api={api} locale={locale} />
            : tab === "attribution" ? <AttributionAdmin api={api} locale={locale} />
            : tab === "audit" ? <AuditAdmin api={api} locale={locale} />
            : loading ? <div className="la-msg">{tx.loading}</div>
            : notAdmin ? <div className="la-msg">{tx.notAdmin}</div>
            : error ? <div className="la-msg">{tx.err}</div>
            : rows.length === 0 ? <div className="la-msg">{tx.empty}</div>
            : rows.map(it => (
              <div key={it.id} className="la-card">
                <div className="la-top">
                  {it.topic ? <span className="la-topic">{it.topic}</span> : null}
                  <span className="la-author">{it.display_name || tx.anon}</span>
                  <span className="la-date">{fmtDate(it.created_at)}</span>
                </div>
                <div className="la-text">{it.body || ""}</div>
                <div className="la-foot">{badge(it.status)}<span className="la-act">{actions(it)}</span></div>
              </div>
            ))}
        </div>
      </div>
    </div>
  );
}

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "fontPreset": "editorial",
  "palette": "violet",
  "showGrain": true,
  "atlasDensity": "balanced"
}/*EDITMODE-END*/;

const LUM_SUPPORTED_LOCALES = ["en", "ru", "uk", "kk", "uz", "es", "fr", "hy"];
function normalizeLumLocale(value) {
  const raw = String(value || "").trim().toLowerCase().replace("_", "-");
  const base = raw.split("-")[0];
  const aliases = { ua: "uk", am: "hy" };
  const locale = aliases[base] || base;
  return LUM_SUPPORTED_LOCALES.includes(locale) ? locale : null;
}
function resolveInitialLumLocale() {
  let saved = null;
  let savedSource = null;
  try {
    saved = normalizeLumLocale(localStorage.getItem("lum-lang"));
    savedSource = localStorage.getItem("lum-lang-source");
  } catch (e) {}
  // Existing installations had no provenance key: preserve their choice as explicit.
  if (saved && (!savedSource || savedSource === "explicit")) return { lang: saved, source: "explicit" };
  try {
    const tg = window.Telegram && window.Telegram.WebApp;
    const tgLocale = normalizeLumLocale(tg && tg.initDataUnsafe && tg.initDataUnsafe.user && tg.initDataUnsafe.user.language_code);
    if (tgLocale) return { lang: tgLocale, source: "telegram" };
  } catch (e) {}
  if (saved && savedSource === "profile") return { lang: saved, source: "profile" };
  try {
    const candidates = (navigator.languages && navigator.languages.length) ? navigator.languages : [navigator.language];
    for (const candidate of candidates) {
      const browserLocale = normalizeLumLocale(candidate);
      if (browserLocale) return { lang: browserLocale, source: "browser" };
    }
  } catch (e) {}
  return { lang: "en", source: "fallback" };
}

function buildLearningSummary(progressData) {
  const D = (typeof window !== "undefined" && window.LUMINARA_DATA) || {};
  const external = D.EXTERNAL || {};
  const byTopic = (progressData && progressData.byTopic) || {};
  let totalScenes = 0;
  let completedScenes = 0;
  let totalTopics = 0;
  let completedTopics = 0;
  const topics = {};
  Object.keys(external).forEach((topic) => {
    const rawScenes = Array.isArray(external[topic] && external[topic].scenes) ? external[topic].scenes : [];
    const scenes = window.LuminaraProgress.visibleScenes(rawScenes);
    if (!scenes.length) return;
    const cursor = byTopic[topic];
    const state = window.LuminaraProgress.topicState(topic, scenes, scenes.length, cursor);
    const read = state.completedCount;
    const completed = state.completed;
    const percent = state.percent;
    topics[topic] = { read, total: scenes.length, completed, percent };
    totalScenes += scenes.length;
    completedScenes += read;
    totalTopics += 1;
    if (completed) completedTopics += 1;
  });
  const quiz = (progressData && progressData.quizSummary) || { attempted: 0, correct: 0, score_pct: null };
  return {
    percent: totalScenes ? Math.round((completedScenes / totalScenes) * 100) : 0,
    completedScenes, totalScenes, completedTopics, totalTopics, topics,
    quiz: {
      attempted: Number(quiz.attempted) || 0,
      correct: Number(quiz.correct) || 0,
      scorePct: Number.isFinite(quiz.score_pct) ? quiz.score_pct : null,
    },
  };
}

function LuminaraApp() {
  // language
  const initialLocale = useMemo_app(() => resolveInitialLumLocale(), []);
  const [lang, setLangRaw] = useState_app(initialLocale.lang);
  const [langSource, setLangSource] = useState_app(initialLocale.source);
  const setLang = (next) => {
    const normalized = normalizeLumLocale(next);
    if (!normalized) return;
    setLangRaw(normalized);
    setLangSource("explicit");
    try {
      localStorage.setItem("lum-lang", normalized);
      localStorage.setItem("lum-lang-source", "explicit");
    } catch (e) {}
    if (!LUM_DEMO && LUM_API && window.LUMINARA_HAS_SESSION) LUM_API.profile.update({ locale: normalized }).catch(() => {});
  };
  useEffect_app(() => {
    try {
      localStorage.setItem("lum-lang", lang);
      localStorage.setItem("lum-lang-source", langSource);
    } catch (e) {}
  }, [lang, langSource]);
  const t = window.LUMINARA_I18N[lang];

  // Mobile drawers are declared before routing so every Back surface can close
  // the active overlay before changing the page.
  const [mobileNavOpen, setMobileNavOpen] = useState_app(false);
  const [railOpen, setRailOpen] = useState_app(false);

  // theme
  const [theme, setTheme] = useState_app(localStorage.getItem("lum-theme") || "dark");
  useEffect_app(() => {
    document.documentElement.setAttribute("data-theme", theme);
    localStorage.setItem("lum-theme", theme);
  }, [theme]);
  // LANG-A11Y: keep <html lang> in sync with the active UI language (was a static
  // lang="en" mismatching mostly-Russian content — wrong for screen readers / SEO).
  useEffect_app(() => {
    document.documentElement.setAttribute("lang", lang);
  }, [lang]);

  // routing
  const [view, setViewRaw] = useState_app(() => {
    // initialize from URL hash so deep links / refresh land on the right screen
    const h = (typeof window !== "undefined" ? window.location.hash : "").replace(/^#\/?/, "");
    const v = h.split("?")[0];
    const known = ["atlas","research","ecosystems","industries","foundations","alphabet","lessons","scene","tools","journal","account","universe","missions","insights","quizzes","admin"];
    return known.includes(v) ? v : "atlas";
  });
  // ROUTER (hash-based — works in Telegram WebView + browser, no server changes):
  // every view maps to #/<view>, so pages get real shareable URLs and the browser/
  // Telegram back button traverses real history. setView pushes a history entry;
  // popstate/hashchange syncs the view back when the user goes Back/Forward.
  const navLock = useRef_app(false);
  // NAV-RESET-FIX: a monotonically increasing signal bumped on EVERY setView call,
  // even when the view is unchanged. Nested sections (e.g. Research, which holds its
  // own openTopic/openSub state) watch this and reset to their root — so clicking an
  // already-active nav item (e.g. "Мои исследования" while reading a scene) returns
  // to the section root instead of being a no-op.
  const [navEpoch, setNavEpoch] = useState_app(0);
  // COLD-LOAD-RERENDER: bumped when Directus content finishes loading (after mount),
  // forcing views to re-read window.LUMINARA_* so deep links land correctly.
  const [dataEpoch, setDataEpoch] = useState_app(0);
  // Hash parameters are meaningful route state too: #/scene?c=A must be able
  // to change to #/scene?c=B without changing the top-level view name.
  const [routeEpoch, setRouteEpoch] = useState_app(0);
  const setView = (v) => {
    setViewRaw(v);
    setNavEpoch((n) => n + 1);
    if (typeof window !== "undefined") {
      const target = "#/" + v;
      if (window.location.hash !== target) {
        navLock.current = true;            // mark programmatic change
        window.location.hash = target;     // pushes a history entry
      }
    }
  };
  // C2 SHARE-SLUG route: resolve #/t/<slug> → the article's research scene, reusing the
  // existing scene deep-link (?t=&s=) + the lum:open-topic runtime open. Index built by the
  // loader (window.LUMINARA_SLUGS: slug → { t, s }). Returns false if the slug is unknown
  // (e.g. Directus content not loaded yet) so callers can retry on lum:data-loaded.
  const openSlug = (slug) => {
    if (typeof window === "undefined") return false;
    const key = decodeURIComponent(String(slug || ""));
    const loc = window.LUMINARA_SLUGS ? window.LUMINARA_SLUGS[key] : null;
    if (!loc) return false;
    navLock.current = true;
    window.location.hash = "#/research?t=" + encodeURIComponent(loc.t) + "&s=" + loc.s;
    setViewRaw("research");
    setNavEpoch((n) => n + 1);
    try {
      const fire = () => { window.dispatchEvent(new CustomEvent("lum:open-topic", { detail: { id: loc.t, sub: loc.s } })); };
      if (typeof requestAnimationFrame === "function") requestAnimationFrame(fire); else setTimeout(fire, 0);
    } catch (e) {}
    return true;
  };
  useEffect_app(() => {
    if (typeof window === "undefined") return;
    // ensure the initial view is reflected in the URL (so first Back has somewhere to go)
    if (!window.location.hash) window.history.replaceState(null, "", "#/" + view);
    const onHash = () => {
      if (navLock.current) { navLock.current = false; return; } // ignore our own push
      const v = window.location.hash.replace(/^#\/?/, "").split("?")[0];
      setRouteEpoch((n) => n + 1);
      if (v.indexOf("t/") === 0) { if (openSlug(v.slice(2))) return; } // C2: #/t/<slug>
      const known = ["atlas","research","ecosystems","industries","foundations","alphabet","lessons","scene","tools","journal","account","universe","missions","insights","quizzes","admin"];
      // Back/Forward can switch from one scene URL to another without
      // unmounting SceneReader. Clear the old model, then resolve the new URL.
      if (v === "scene") {
        setOpenChapter(null);
        setLessonTopic(null);
        setSceneRouteState(null);
      }
      setViewRaw(known.includes(v) ? v : "atlas");
    };
    window.addEventListener("hashchange", onHash);
    // C2 cold-link: #/t/<slug> may arrive before Directus content (and the slug index) is
    // ready — retry once data has loaded.
    const trySlug = () => {
      const p = window.location.hash.replace(/^#\/?/, "").split("?")[0];
      if (p.indexOf("t/") === 0) openSlug(p.slice(2));
    };
    trySlug();
    window.addEventListener("lum:data-loaded", trySlug);
    return () => { window.removeEventListener("hashchange", onHash); window.removeEventListener("lum:data-loaded", trySlug); };
  }, []);
  // a real "back" that uses browser history (falls back to atlas if no history)
  const goBack = () => {
    if (railOpen) { setRailOpen(false); return; }
    if (mobileNavOpen) { setMobileNavOpen(false); return; }
    if (typeof window !== "undefined" && window.history.length > 1) { window.history.back(); return; }
    // NAV-BACK-COLDLOAD: no in-app history (opened via direct/shared URL or refresh) —
    // fall back to the current view's semantic parent, not always atlas.
    const PARENT = { scene: "foundations", lessons: "foundations", foundations: "research",
                     ecosystems: "research", industries: "research", research: "atlas",
                     missions: "atlas", insights: "atlas", universe: "atlas", admin: "atlas" };
    setView(PARENT[view] || "atlas");
  };
  const contentGroupTitle = (key) => {
    const group = contentGroupForKey(key);
    return group === "ecosystems" ? t.nav.ecosystems : group === "industries" ? t.nav.industries : t.nav.foundations;
  };
  const backToContentGroup = (key) => {
    setLessonTopic(null);
    setOpenChapter(null);
    setAlphaStart(null);
    setView(contentGroupForKey(key));
  };
  // URL-ROUTING: drive the Telegram Mini App BackButton from the router — show it on
  // every non-root view, tap walks browser history (same as goBack). The button stays
  // in sync as the view changes. Harmless outside Telegram (no WebApp object).
  useEffect_app(() => {
    if (typeof window === "undefined") return;
    const tg = window.Telegram && window.Telegram.WebApp;
    const bb = tg && tg.BackButton;
    if (!bb) return;
    // QA-010: BackButton needs Bot API 6.1+. On 6.0 clients calling show/onClick logs
    // "BackButton is not supported in version 6.0" repeatedly — guard by capability.
    if (typeof tg.isVersionAtLeast === "function" && !tg.isVersionAtLeast("6.1")) return;
    const onBack = () => goBack();
    try { (view === "atlas" ? bb.hide() : bb.show()); } catch (e) {}
    try { bb.onClick(onBack); } catch (e) {}
    return () => { try { bb.offClick(onBack); } catch (e) {} };
  }, [view, mobileNavOpen, railOpen]);
  const [openChapter, setOpenChapter] = useState_app(null);
  const [sceneRouteState, setSceneRouteState] = useState_app(null);
  const [alphaStart, setAlphaStart] = useState_app(null); // Крипто-Азбука: scene idx of the tapped letter
  // Open a Foundations chapter — shared by the Foundations page AND the nav tree
  // (NAV-FOUNDATIONS-EXPAND). Pulls authored Directus scenes (EXTERNAL[c.key]) and routes by
  // layout: alphabet grid / lesson list / plain scene.
  const openFoundationChapter = (c) => {
    const external = ((window.LUMINARA_DATA || {}).EXTERNAL || {});
    const ext = external[c.key];
    const es = (ext && Array.isArray(ext.scenes)) ? ext.scenes : [];
    let ch = es.length ? { ...c, extScenes: es, scenes: es.length } : c;
    // Ethereum White Paper is a separate CMS-backed course, but it is presented
    // as the bonus card after the 14 Ethereum lessons. Build the same chapter
    // shape for every entry path, including a cold #/lessons reload.
    if (c.key === "eth-atlas") {
      const whitePaper = external["ethereum-whitepaper"];
      const sceneCount = whitePaper && Array.isArray(whitePaper.scenes) ? whitePaper.scenes.length : 0;
      const whitePaperCourse = sceneCount
        ? {
            key: "ethereum-whitepaper",
            title: (whitePaper.ecosystem && (whitePaper.ecosystem.titleMl || whitePaper.ecosystem.title))
              || { ru: "Ethereum White Paper", en: "Ethereum White Paper" },
            scenes: sceneCount,
          }
        : null;
      ch = { ...ch, bonusCourse: whitePaperCourse };
    }
    const group = contentGroupForKey(c.key);
    if (c.layout === "alphabet") { setOpenChapter(ch); setView("alphabet"); }
    else if (c.layout === "lessons") { setAlphaStart(null); setOpenChapter(ch); setView("lessons"); }
    else { setAlphaStart(null); setOpenChapter(ch); setView("scene"); }
    // A generic #/lessons cannot identify Ethereum versus Крипто-ясли after a
    // reload. Persist both the course and its semantic parent in the URL.
    if (typeof window !== "undefined" && window.LUM_ROUTE) {
      window.LUM_ROUTE.set("c", c.key);
      window.LUM_ROUTE.set("g", group);
    }
  };
  // QUIZ-COMPLETION (ticket 64): after the chapter quiz the reader needs a real next step. The next
  // target is resolved HERE (the shell owns navigation, locale and entitlement context) and handed to
  // the reader as a callback — the quiz component never builds its own URLs.
  const nextChapterAfter = (c) => {
    if (!c || !c.key) return null;
    const list = ((window.LUMINARA_DATA || {}).CHAPTERS || []).filter((x) => x && x.key);
    const i = list.findIndex((x) => x.key === c.key);
    if (i < 0 || i + 1 >= list.length) return null;
    return list[i + 1];
  };

  // Open a single lesson (e.g. an ETH-atlas lesson) straight from the nav tree — mirrors the
  // LessonList onOpenLesson path so "back" from the scene returns correctly.
  const openTreeLesson = (les) => {
    if (!les || !les.key) return;
    const ext = ((window.LUMINARA_DATA || {}).EXTERNAL || {})[les.key];
    const es = (ext && Array.isArray(ext.scenes)) ? ext.scenes : [];
    setLessonTopic({ topic: les.key, chapter: { key: les.key, title: les.title, scenes: es.length || les.scenes || 0, extScenes: es } });
    setView("scene");
    if (typeof window !== "undefined" && window.LUM_ROUTE) window.LUM_ROUTE.set("g", contentGroupForKey(les.key));
  };
  const openCatalogEntry = (entry) => {
    if (!entry || !entry.id) return;
    if (typeof window !== "undefined" && window.LUM_ROUTE && entry.group) window.LUM_ROUTE.set("g", entry.group);
    if (entry.chapter) {
      const chapter = ((window.LUMINARA_DATA || {}).CHAPTERS || []).find((c) => c.key === entry.chapter);
      if (chapter) openFoundationChapter(chapter);
      return;
    }
    const topicId = canonicalResearchTopic(entry.id);
    setView("research");
    setTimeout(() => {
      if (window.LUM_ROUTE) {
        window.LUM_ROUTE.set("t", topicId);
        window.LUM_ROUTE.set("s", null);
        window.LUM_ROUTE.set("wp", null);
        window.LUM_ROUTE.set("c", null);
        window.LUM_ROUTE.set("l", null);
      }
      try { window.dispatchEvent(new CustomEvent("lum:open-topic", { detail: { id: topicId } })); } catch (e) {}
    }, 0);
  };
  const [selectedNode, setSelectedNode] = useState_app(() => {
    const fromUrl = (typeof window !== "undefined" && window.LUM_ROUTE) ? window.LUM_ROUTE.get("n") : null;
    return fromUrl || "ethereum";
  });
  // M2/M3: mobile overlays — nav drawer + node-detail bottom sheet. Desktop ignores both.
  const isMobile = () => window.matchMedia("(max-width: 860px)").matches;
  // Selecting a node opens the detail sheet on mobile; on desktop it just updates the rail.
  const selectNode = (id) => { if (!id) return; setSelectedNode(id); if (isMobile()) setRailOpen(true); };
  // URL-SUBSTATE: reflect the focused atlas node in the hash (#/atlas?n=<id>) so a
  // refresh/share restores it — only while on the atlas view; other views own theirs.
  useEffect_app(() => {
    if (view === "atlas" && typeof window !== "undefined" && window.LUM_ROUTE) window.LUM_ROUTE.set("n", selectedNode);
  }, [view, selectedNode]);
  useEffect_app(() => {
    if (!mobileNavOpen && !railOpen) return;
    const onKey = (e) => { if (e.key === "Escape") { setMobileNavOpen(false); setRailOpen(false); } };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [mobileNavOpen, railOpen]);
  const [savedIds, setSavedIds] = useState_app(() => {
    try { return new Set(JSON.parse(localStorage.getItem("lum-saved") || "[]")); } catch { return new Set(); }
  });
  useEffect_app(() => {
    localStorage.setItem("lum-saved", JSON.stringify(Array.from(savedIds)));
  }, [savedIds]);

  // progress map (TICKET-006): completed nodes light up; seed a demo path on first run
  const [completed, setCompleted] = useState_app(() => {
    try {
      const raw = localStorage.getItem("lum-completed");
      if (raw) return new Set(JSON.parse(raw));
    } catch {}
    return LUM_DEMO ? new Set(["ethereum", "ton", "defi", "wallet"]) : new Set();
  });
  useEffect_app(() => {
    localStorage.setItem("lum-completed", JSON.stringify(Array.from(completed)));
  }, [completed]);
  const markCompleted = (id) => setCompleted(s => { const n = new Set(s); n.add(id); return n; });

  const toggleSaved = (id) => {
    setSavedIds(s => {
      const n = new Set(s);
      if (n.has(id)) n.delete(id); else n.add(id);
      return n;
    });
  };

  // TICKET-014: jump from a question (hero / journal) straight to its atlas node
  const jumpToNode = (id) => { if (id) { selectNode(id); setView("atlas"); } };

  // TICKET-016: viral onboarding (hook → insight → map) on first visit
  const [showOnboard, setShowOnboard] = useState_app(() => {
    try { return localStorage.getItem("lum-onboarded") !== "1"; } catch { return true; }
  });
  const finishOnboard = () => {
    try { localStorage.setItem("lum-onboarded", "1"); } catch {}
    setShowOnboard(false);
  };

  // ── live session (S2-BIG.2) ──────────────────────────────────────────────
  // me === null until /me resolves (real mode). Demo mode is instantly ready.
  const [me, setMe] = useState_app(null);
  const [progressData, setProgressData] = useState_app({ byTopic: {}, quizByTopic: {}, quizSummary: { attempted: 0, correct: 0, score_pct: null } });
  const [authReady, setAuthReady] = useState_app(LUM_DEMO);
  // Monotonic session revision: protected readers re-check server access whenever
  // the current session is (re)resolved. Never derive authorization from client role/plan.
  const [authRevision, setAuthRevision] = useState_app(0);
  useEffect_app(() => {
    if (LUM_DEMO || !LUM_API) { setAuthReady(true); return; }
    let alive = true;
    // Initialise the Telegram webview ASAP — full height + correct viewport —
    // regardless of session state. No-op/harmless outside Telegram.
    try {
      const tg0 = window.Telegram && window.Telegram.WebApp;
      if (tg0) { tg0.ready(); tg0.expand(); }
    } catch (e0) { /* older webview */ }
    (async () => {
      try {
        // Capture deterministic attribution before login. Telegram start_param wins,
        // then ?ref=, then utm_source only when explicitly prefixed with ref_.
        try {
          const tgEntry = window.Telegram && window.Telegram.WebApp;
          const startParam = tgEntry && tgEntry.initDataUnsafe && tgEntry.initDataUnsafe.start_param;
          const pending = window.LUMINARA_parseReferralAttribution({
            startParam, search: window.location.search || "",
          });
          if (pending) {
            // Issue #59: keep the parser's ref/priority (start_param > ref > legacy utm_ref)
            // untouched, but attach the landing page's channel/campaign UTM as ANALYTICS
            // CONTEXT so the registration can be grouped by source/campaign in referrals.meta.
            // UTM never becomes the ref code, and never overwrites a value the parser set.
            if (typeof window.LUMINARA_parseShareUtm === "function") {
              const utm = window.LUMINARA_parseShareUtm(window.location.search || "");
              if (utm.utm_source && !pending.utm_source) pending.utm_source = utm.utm_source;
              if (utm.utm_medium && !pending.utm_medium) pending.utm_medium = utm.utm_medium;
              if (utm.utm_campaign && !pending.utm_campaign) pending.utm_campaign = utm.utm_campaign;
            }
            localStorage.setItem("lum_pending_ref_v2", JSON.stringify(pending));
          }
        } catch (eRef) {}
        let ok = await LUM_API.bootstrap();        // 1) lum_rt cookie → access token
        if (!ok) {
          // 2) S2-T1: Telegram Mini App auto-login (no-op outside Telegram).
          const tg = window.Telegram && window.Telegram.WebApp;
          if (tg && tg.initData) {
            try { tg.ready(); tg.expand(); } catch (e2) { /* older webview */ }
            try {
              await LUM_API.auth.telegram(tg.initData);   // HMAC-verified server-side; sets lum_rt + token
              ok = true;
            } catch (e2) { /* telegram login failed → fall through to login screen */ }
          }
        }
        if (!ok) {
          // C5: a guest arriving via a share-link (#/t/<slug>) reads one free article instead
          // of bouncing to login; read-once → Paywall is enforced in the reader (see isGuest).
          if (/^#\/t\//.test(window.location.hash || "")) {
            if (alive) { setMe({ user: null, guest: true }); setAuthRevision((r) => r + 1); }
            return;
          }
          window.location.href = "./Luminara_Auth.html"; return;  // 3) no session → login
        }
        const data = await LUM_API.profile.me();   // { user, level, ... }
        try {
          const activationData = await LUM_API.profile.activation();
          data.activation = activationData && activationData.activation;
          window.LUMINARA_ACTIVATION = data.activation || null;
        } catch (eActivation) { data.activation = null; }
        if (alive) {
          window.LUMINARA_HAS_SESSION = true;
          setMe(data);
          setAuthRevision((r) => r + 1);
          const profileLocale = normalizeLumLocale(data && data.user && data.user.locale);
          let source = null;
          try { source = localStorage.getItem("lum-lang-source"); } catch (eLocale) {}
          if (profileLocale && source !== "explicit") {
            setLangRaw(profileLocale);
            setLangSource("profile");
          }
        }
        // Apply deterministic attribution once. With no explicit code, the server may use
        // its HttpOnly click cookie or mark a probabilistic match for manual review.
        try {
          const rawPending = localStorage.getItem("lum_pending_ref_v2");
          const pending = rawPending ? JSON.parse(rawPending) : null;
          const result = pending
            ? await LUM_API.referral.apply(pending.code, pending)
            : await LUM_API.referral.apply(null, { source: "click_cookie" });
          if (rawPending && result) localStorage.removeItem("lum_pending_ref_v2");
        } catch (eApply) { console.error("Luminara: referral apply failed —", eApply); }
        try { const rc = await LUM_API.referral.code(); if (rc && rc.code) window.LUMINARA_REF_CODE = rc.code; } catch (eC) {}
      } catch (e) {
        if (e && e.isAuth) { window.location.href = "./Luminara_Auth.html"; return; }
        console.error("Luminara: session bootstrap failed —", e);
      } finally {
        if (alive) setAuthReady(true);
      }
    })();
    return () => { alive = false; };
  }, []);

  // JOURNAL-REAL-DATA: heartbeat while the tab is visible → accumulates ACTIVE HOURS.
  // Real mode + authenticated only (guests/demo excluded).
  useEffect_app(() => {
    if (LUM_DEMO || !LUM_API || !me || me.guest || !me.user) return;
    const TICK = 30; // seconds per ping
    const ping = () => {
      if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
      try { LUM_API.stats.heartbeat(TICK).catch(() => {}); } catch (e) {}
    };
    const iv = setInterval(ping, TICK * 1000);
    return () => clearInterval(iv);
  }, [me]);

  // Derived identity (UI gating only; admin endpoints re-check role server-side).
  const isGuest = !!(me && me.guest);   // C5: share-link visitor, not logged in
  const role = (me && me.user) ? (me.user.role != null ? me.user.role : me.user.role_id) : null;
  const isAdmin = role === "moderator" || role === "admin" || role === "superadmin" || role === 2 || role === 3 || role === 4;
  const accountInitial = LUM_DEMO
    ? "N"
    : ((me && me.user && me.user.display_name) ? me.user.display_name.trim().charAt(0).toUpperCase() : "?");

  // S2-BIG.3: lesson opened from an atlas node ({ topic, chapter }), plus a /me
  // refresh so the header/account reflect points awarded by the quiz.
  const [lessonTopic, setLessonTopic] = useState_app(null);
  const refreshMe = async () => {
    if (LUM_DEMO || !LUM_API) return;
    try {
      const [d, activationData] = await Promise.all([LUM_API.profile.me(), LUM_API.profile.activation()]);
      d.activation = activationData && activationData.activation;
      window.LUMINARA_ACTIVATION = d.activation || null;
      setMe(d);
      setAuthRevision((r) => r + 1);
    } catch (e) { /* non-fatal */ }
  };
  const refreshLearning = async () => {
    if (LUM_DEMO || !LUM_API || !me || me.guest || !me.user) return;
    try {
      const data = await LUM_API.progress.get();
      setProgressData(data || { byTopic: {}, quizByTopic: {}, quizSummary: { attempted: 0, correct: 0, score_pct: null } });
    } catch (e) { /* non-fatal: retain the last honest snapshot */ }
  };
  useEffect_app(() => { refreshLearning(); }, [me]);
  useEffect_app(() => {
    const h = () => refreshLearning();
    window.addEventListener("lum:progress-changed", h);
    return () => window.removeEventListener("lum:progress-changed", h);
  }, [me]);
  const learningSummary = useMemo_app(() => buildLearningSummary(progressData), [progressData, dataEpoch]);
  // FE-1: components that award points server-side (e.g. the quiz) fire a global
  // "lum:points-changed" event; refresh /me so the header/account reflect it without
  // threading a callback through every intermediate layer.
  useEffect_app(() => {
    const h = () => { refreshMe(); };
    window.addEventListener("lum:points-changed", h);
    return () => window.removeEventListener("lum:points-changed", h);
  }, []);
  useEffect_app(() => {
    const h = (event) => {
      const activation = event && event.detail;
      if (activation) {
        setMe(current => current ? { ...current, activation } : current);
        setAuthRevision((r) => r + 1);
      }
    };
    window.addEventListener("lum:activation-changed", h);
    return () => window.removeEventListener("lum:activation-changed", h);
  }, []);
  // COLD-LOAD-RERENDER: when Directus content arrives after mount, re-render so views
  // (atlas, research/WP deep links, foundations) reflect the freshly loaded globals.
  useEffect_app(() => {
    if (typeof window !== "undefined" && window.LUMINARA_CONTENT_SOURCE === "directus") return;
    const h = () => setDataEpoch(e => e + 1);
    window.addEventListener("lum:data-loaded", h);
    return () => window.removeEventListener("lum:data-loaded", h);
  }, []);
  const openLesson = (id, startScene) => {
    // ETH-ATLAS: some atlas nodes now lead to a Foundations chapter (e.g. "ethereum" → eth-atlas)
    // instead of their own scenes. Open that chapter's lesson list rather than an empty topic.
    if (NODE_CHAPTER_REDIRECT[id]) {
      const chap = ((window.LUMINARA_DATA || {}).CHAPTERS || []).find(c => c.key === NODE_CHAPTER_REDIRECT[id]);
      if (chap) { openFoundationChapter(chap); return; }
    }
    const D = window.LUMINARA_DATA || {};
    const node = (D.NODES || []).find(n => n.id === id);
    const eco = (D.ECOSYSTEMS || []).find(e => e.id === id);
    const ins = (D.INSIGHTS || {})[id];
    const ext = (D.EXTERNAL || {})[id];
    const extScenes = (ext && Array.isArray(ext.scenes)) ? ext.scenes : [];
    const chapter = {
      n: 0, key: id,
      title: (node && node.title) || (eco && eco.title) || { en: id },
      kicker: (ins && ins.kicker) || { en: "" },
      scenes: extScenes.length,
      // SCENEREADER-CONTENT: carry the real authored scene objects so the reader
      // renders actual lesson text (title/body/insight/tags) instead of the
      // Foundations placeholder. Empty array → reader shows an honest empty state.
      extScenes,
    };
    setLessonTopic({ topic: id, chapter, startScene: Number.isInteger(startScene) ? startScene : undefined });
    setView("scene");
    if (typeof window !== "undefined" && window.LUM_ROUTE) window.LUM_ROUTE.set("g", contentGroupForKey(id));
  };

  // #3 SCENE-DEEP-STATE: persist which lesson/chapter is open in the hash
  // (#/scene?c=<key>&s=<index>) so a reload reconstructs the exact lesson location.
  useEffect_app(() => {
    if (view !== "scene" || typeof window === "undefined" || !window.LUM_ROUTE) return;
    const key = lessonTopic ? lessonTopic.topic : (openChapter ? openChapter.key : null);
    // A cold / Back-forward scene URL arrives before its in-memory chapter object.
    // Do not mutate that URL during the unresolved render: otherwise deleting `c`
    // turns a valid #/scene?c=<key>&s=<index> into a keyless route and the resolver
    // correctly (but wrongly for the user) falls back to Atlas.
    if (!key) return;
    window.LUM_ROUTE.set("c", key);
    window.LUM_ROUTE.set("g", contentGroupForKey(key));
    // NAV-UNIQUE-URL-AUDIT: reflect the Крипто-Азбука letter index (#/scene?c=kripto-azbuka&l=<i>)
    // so every letter has a unique, shareable, reload-safe URL.
    const isAbc = !lessonTopic && openChapter && openChapter.layout === "alphabet";
    window.LUM_ROUTE.set("l", (isAbc && alphaStart != null) ? String(alphaStart) : null);
  }, [view, openChapter, lessonTopic, alphaStart]);

  // On a cold load straight into #/scene?c=<key>, rebuild the lesson: a Foundations
  // chapter (CHAPTERS) or an authored research lesson (EXTERNAL). Do not decide
  // that a route is unknown while the catalogue is still loading. Once it settles,
  // an invalid or unavailable key keeps a controlled recovery view — never blank.
  useEffect_app(() => {
    if (view !== "scene" || openChapter || lessonTopic) return;
    const key = (typeof window !== "undefined" && window.LUM_ROUTE) ? window.LUM_ROUTE.get("c") : null;
    if (!key) { setView("atlas"); return; }
    const contentStatus = (typeof window !== "undefined" && window.LUMINARA_CONTENT_STATUS) || "ready";
    if (contentStatus === "loading") { setSceneRouteState("loading"); return; }
    const D = window.LUMINARA_DATA || {};
    const chap = (D.CHAPTERS || []).find(c => c.key === key);
    // NAV-UNIQUE-URL-AUDIT: scene index from ?s=<i> (lesson/spine scenes), restored on cold load.
    const sRaw = window.LUM_ROUTE.get("s");
    const parsedScene = sRaw != null && sRaw !== "" ? Number(sRaw) : null;
    const sIdx = Number.isInteger(parsedScene) && parsedScene >= 0 ? parsedScene : null;
    if (chap) {
      const ext = (D.EXTERNAL || {})[key];
      const es = (ext && Array.isArray(ext.scenes)) ? ext.scenes : [];
      if (!es.length && contentStatus === "unavailable") { setSceneRouteState("unavailable"); return; }
      const base = es.length ? { ...chap, extScenes: es, scenes: es.length } : chap;
      setSceneRouteState(null);
      setOpenChapter((sIdx != null && chap.layout !== "alphabet") ? { ...base, startScene: sIdx } : base);
      // Крипто-Азбука letter from ?l=<i> on a cold load.
      if (chap.layout === "alphabet") {
        const li = window.LUM_ROUTE.get("l");
        if (li != null && li !== "") setAlphaStart(parseInt(li, 10) || 0);
      }
      return;
    }
    const ext = (D.EXTERNAL || {})[key];
    if (ext && Array.isArray(ext.scenes) && ext.scenes.length) {
      setSceneRouteState(null);
      openLesson(key, sIdx != null ? sIdx : undefined);
    } else setSceneRouteState("unavailable");
  }, [view, openChapter, lessonTopic, dataEpoch, routeEpoch]);

  // A chapter may have been opened from static navigation while the catalogue was
  // still loading. Upgrade that already-open reader as soon as its authoritative
  // scene list arrives rather than leaving it with an empty static shell.
  useEffect_app(() => {
    if (view !== "scene") return;
    const ext = ((window.LUMINARA_DATA || {}).EXTERNAL || {});
    if (openChapter && !(openChapter.extScenes && openChapter.extScenes.length)) {
      const scenes = ext[openChapter.key] && ext[openChapter.key].scenes;
      if (Array.isArray(scenes) && scenes.length) {
        setOpenChapter((current) => current && current.key === openChapter.key
          ? { ...current, extScenes: scenes, scenes: scenes.length }
          : current);
      }
    }
    if (lessonTopic && !(lessonTopic.chapter && lessonTopic.chapter.extScenes && lessonTopic.chapter.extScenes.length)) {
      const scenes = ext[lessonTopic.topic] && ext[lessonTopic.topic].scenes;
      if (Array.isArray(scenes) && scenes.length) {
        setLessonTopic((current) => current && current.topic === lessonTopic.topic
          ? { ...current, chapter: { ...current.chapter, extScenes: scenes, scenes: scenes.length } }
          : current);
      }
    }
  }, [view, openChapter, lessonTopic, dataEpoch]);

  // Cold load / refresh into a chapter list restores the explicit course key. Legacy
  // keyless #/alphabet and #/lessons URLs still use their old Foundations fallback.
  useEffect_app(() => {
    if ((view !== "alphabet" && view !== "lessons") || (openChapter && openChapter.layout === view)) return;
    const wantLayout = view;
    const open = () => {
      if (typeof window === "undefined") return;
      const D = window.LUMINARA_DATA || {};
      const routeKey = window.LUM_ROUTE && window.LUM_ROUTE.get("c");
      const chap = (D.CHAPTERS || []).find((c) => c.key === routeKey && c.layout === wantLayout)
        || (!routeKey ? (D.CHAPTERS || []).find((c) => c.layout === wantLayout) : null);
      if (!chap) return;
      const ext = (D.EXTERNAL || {})[chap.key];
      const ready = wantLayout === "lessons"
        ? Array.isArray(chap.lessons)
        : (ext && Array.isArray(ext.scenes) && ext.scenes.length > 0);
      if (!ready) return; // content not wired yet → wait for lum:data-loaded
      openFoundationChapter(chap);
    };
    open();
    const onData = () => open();
    window.addEventListener("lum:data-loaded", onData);
    return () => window.removeEventListener("lum:data-loaded", onData);
  }, [view, openChapter]);

  // tweaks (starter components live on window directly)
  const [tweaks, setTweakBase] = window.useTweaks((() => {
    try {
      const saved = JSON.parse(localStorage.getItem("lum-tweaks") || "null");
      return { ...TWEAK_DEFAULTS, ...(saved || {}) };
    } catch { return TWEAK_DEFAULTS; }
  })());
  const setTweak = (k, v) => {
    setTweakBase(k, v);
    try {
      const cur = JSON.parse(localStorage.getItem("lum-tweaks") || "{}");
      const edits = typeof k === "string" ? { [k]: v } : k;
      localStorage.setItem("lum-tweaks", JSON.stringify({ ...cur, ...edits }));
    } catch {}
  };

  // apply tweaks via data attributes
  useEffect_app(() => {
    document.documentElement.setAttribute("data-font", tweaks.fontPreset || "editorial");
    document.documentElement.setAttribute("data-palette", tweaks.palette || "violet");

    // dynamic favicon — recoloured to the selected palette
    const HUES = { violet: 295, cobalt: 250, ember: 50, forest: 170, crimson: 15 };
    const h = HUES[tweaks.palette || "violet"] ?? 295;
    const hsl = (l, s) => `hsl(${h}, ${s}%, ${l}%)`;
    const svg =
      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">` +
      `<defs><radialGradient id="g" cx="50%" cy="38%" r="62%">` +
      `<stop offset="0%" stop-color="${hsl(82, 70)}"/>` +
      `<stop offset="55%" stop-color="${hsl(60, 75)}"/>` +
      `<stop offset="100%" stop-color="${hsl(28, 60)}"/>` +
      `</radialGradient></defs>` +
      `<rect width="32" height="32" rx="8" fill="#0a0a1a"/>` +
      `<circle cx="16" cy="16" r="9" fill="url(#g)"/>` +
      `<circle cx="16" cy="16" r="13" fill="none" stroke="${hsl(58, 70)}" stroke-opacity="0.55" stroke-width="0.8"/>` +
      `<circle cx="16" cy="16" r="2.6" fill="#f5f3ff"/></svg>`;
    let link = document.querySelector('link[rel="icon"]');
    if (!link) { link = document.createElement("link"); link.rel = "icon"; document.head.appendChild(link); }
    link.type = "image/svg+xml";
    link.href = "data:image/svg+xml," + encodeURIComponent(svg);
  }, [tweaks.fontPreset, tweaks.palette]);

  if (!authReady) return <LumBootSplash />;

  return (
    <div className={"app" + (mobileNavOpen ? " nav-open" : "") + (railOpen ? " rail-open" : "") + (view === "scene" ? " app--reading" : "")}>
      <Topbar t={t} view={view} setView={(v) => { setView(v); setRailOpen(false); }} lang={lang} setLang={setLang}
              theme={theme} setTheme={setTheme}
              palette={tweaks.palette} setPalette={(v) => setTweak("palette", v)}
              onToggleNav={() => setMobileNavOpen((v) => !v)}
              accountInitial={accountInitial} isAdmin={isAdmin} onAdmin={() => setView("admin")}
              onOpenChapter={openFoundationChapter} onOpenLesson={openTreeLesson} />

      {langSource === "fallback" && <LocalePrompt locale={lang} onChoose={setLang} />}


      <Sidebar t={t} view={view}
               setView={(v) => { setView(v); setMobileNavOpen(false); }}
               savedCount={savedIds.size}
               learning={learningSummary}
               open={mobileNavOpen}
               onClose={() => setMobileNavOpen(false)}
               selectedNode={selectedNode}
               onPickNode={(id) => { selectNode(id); setView("atlas"); setMobileNavOpen(false); }} />

      {/* M2: tap-out backdrop for the mobile drawer (no-op on desktop) */}
      <div className="nav-backdrop" onClick={() => setMobileNavOpen(false)} aria-hidden="true" />

      <div className="canvas-area">
        <div className="view-scroll">
          <LumViewBoundary key={view + ":" + navEpoch}>
          {view === "atlas" && (
            <HeroAtlas t={t} locale={lang} selected={selectedNode} onSelect={selectNode}
                       completed={completed} onComplete={markCompleted}
                       learningProgressPct={learningSummary.percent}
                       onJump={jumpToNode}
                       onCta={() => setView("foundations")}
                       onHowItWorks={() => setShowOnboard(true)} />
          )}
          {view === "research" && (
            <Research t={t} locale={lang} navEpoch={navEpoch} isGuest={isGuest}
                      authReady={authReady} authRevision={authRevision}
                      catalog={RESEARCH_CATALOG}
                      onOpenGroup={(group) => setView(group)}
                      onAtlas={(id) => { selectNode(id); setView("atlas"); }} />
          )}
          {view === "foundations" && (
            <Foundations t={t} locale={lang} onOpen={openFoundationChapter} progressData={progressData} />
          )}
          {view === "alphabet" && openChapter && (
            <AlphabetGrid t={t} locale={lang} chapter={openChapter}
                          onOpenLetter={(i) => { setAlphaStart(i); setView("scene"); }}
                          onBack={() => backToContentGroup(openChapter.key)} backLabel={contentGroupTitle(openChapter.key)} />
          )}
          {view === "lessons" && openChapter && (
            <LessonList t={t} locale={lang} chapter={openChapter}
                        onOpenLesson={(les) => {
                          const ext = ((window.LUMINARA_DATA || {}).EXTERNAL || {})[les.key];
                          const es = (ext && Array.isArray(ext.scenes)) ? ext.scenes : [];
                          // reuse the lessonTopic slot so "back" from the scene returns to the list
                          setLessonTopic({ topic: les.key, chapter: { key: les.key, title: les.title, scenes: es.length || les.scenes || 0, extScenes: es } });
                          setView("scene");
                          if (window.LUM_ROUTE) window.LUM_ROUTE.set("g", contentGroupForKey(les.key));
                        }}
                        onBack={() => backToContentGroup(openChapter.key)} backLabel={contentGroupTitle(openChapter.key)} />
          )}
          {view === "scene" && (openChapter || lessonTopic) && (
            <SceneReader key={(lessonTopic ? lessonTopic.topic : openChapter.key)} t={t} locale={lang}
                         chapter={lessonTopic ? lessonTopic.chapter : openChapter}
                         topic={lessonTopic ? lessonTopic.topic : (openChapter ? openChapter.key : null)}
                         initialScene={(!lessonTopic && openChapter && openChapter.layout === "alphabet")
                           ? alphaStart
                           : (lessonTopic && Number.isInteger(lessonTopic.startScene) ? lessonTopic.startScene
                              : (openChapter && Number.isInteger(openChapter.startScene) ? openChapter.startScene : null))}
                         viewer={(me && me.user) ? me.user : null}
                         authReady={authReady} authRevision={authRevision}
                         onNextLesson={(() => {
                           const nx = lessonTopic ? null : nextChapterAfter(openChapter);
                           return nx ? () => openFoundationChapter(nx) : null;
                         })()}
                         nextLessonLabel={(() => {
                           const nx = lessonTopic ? null : nextChapterAfter(openChapter);
                           return nx ? ((nx.title && (nx.title[lang] || nx.title.en || nx.title.ru)) || null) : null;
                         })()}
                         onPointsChanged={refreshMe}
                         onBack={() => {
                           // NAV-BACK-COLDLOAD: go to the semantic parent explicitly rather than
                           // history.back() (which lands on atlas when opened via a direct/shared URL).
                           if (lessonTopic) {
                             setLessonTopic(null);
                             if (openChapter) { setView("lessons"); if (window.LUM_ROUTE) window.LUM_ROUTE.set("c", openChapter.key); }
                             else backToContentGroup(lessonTopic.topic);
                             return;
                           }
                           backToContentGroup(openChapter.key);
                         }} backLabel={contentGroupTitle(lessonTopic ? lessonTopic.topic : openChapter.key)} />)}
          {view === "scene" && !(openChapter || lessonTopic) && (
            <SceneRouteState locale={lang}
                             loading={sceneRouteState === "loading" || ((typeof window !== "undefined" && window.LUMINARA_CONTENT_STATUS) === "loading")}
                             onBack={() => { setSceneRouteState(null); backToContentGroup((typeof window !== "undefined" && window.LUM_ROUTE) ? window.LUM_ROUTE.get("c") : null); }} />)}
          {view === "ecosystems" && (
            <ResearchCatalogPage t={t} locale={lang} group="ecosystems"
                                 catalog={RESEARCH_CATALOG} onOpenEntry={openCatalogEntry}
                                 onBack={() => setView("research")} />
          )}
          {view === "industries" && (
            <ResearchCatalogPage t={t} locale={lang} group="industries"
                                 catalog={RESEARCH_CATALOG} onOpenEntry={openCatalogEntry}
                                 onBack={() => setView("research")} />
          )}
          {view === "tools"   && <Tools t={t} />}
          {view === "journal" && (
            <Journal t={t} locale={lang} onOpenAccount={() => setView("account")} onJump={jumpToNode} />
          )}
          {view === "account" && <Account t={t} locale={lang} me={me} learning={learningSummary} onProfileChanged={refreshMe} onOpenMissions={() => setView("missions")} onOpenInsights={() => setView("insights")} onOpenUniverse={() => setView("universe")} />}
          {view === "universe" && (
            <MyUniverse t={t} locale={lang} completed={completed} progressData={progressData} onBack={goBack} />
          )}
          {view === "missions" && (
            <Missions t={t} locale={lang} onBack={goBack} onPointsChanged={refreshMe} />
          )}
          {view === "quizzes" && <QuizzesCatalog t={t} locale={lang} authRevision={authRevision} onOpenAccount={() => setView("account")} />}
          {view === "insights" && (
            <Insights t={t} locale={lang} onBack={goBack} />
          )}
          {view === "admin" && isAdmin && (
            <AdminPanel locale={lang} role={role} api={LUM_API} onBack={goBack} />
          )}
          <SiteFooter t={t} locale={lang} setView={setView} />
          </LumViewBoundary>
        </div>
      </div>

      {view === "atlas" && (
        <Rail t={t} locale={lang} selected={selectedNode} setSelected={setSelectedNode} open={railOpen}
              saved={savedIds.has(selectedNode)} onSave={() => toggleSaved(selectedNode)}
              onClose={() => setRailOpen(false)}
              onReadLesson={openLesson} />
      )}
      {/* M3: tap-out backdrop for the mobile node sheet (no-op on desktop) */}
      <div className="rail-backdrop" onClick={() => setRailOpen(false)} aria-hidden="true" />

      <InsightFeedback locale={lang} />

      {showOnboard && (
        <ViralOnboarding t={t} locale={lang} completed={completed}
                         onClose={finishOnboard}
                         onEnter={() => { finishOnboard(); setView("atlas"); }} />
      )}

      <window.TweaksPanel title="Tweaks">
        <window.TweakSection label="Palette">
          <window.TweakRadio label="Color" value={tweaks.palette}
            options={[
              {value:"violet",label:"Violet"},
              {value:"cobalt",label:"Cobalt"},
              {value:"ember", label:"Ember"},
              {value:"forest",label:"Forest"},
              {value:"crimson",label:"Crimson"},
            ]}
            onChange={v => setTweak("palette", v)} />
        </window.TweakSection>
        <window.TweakSection label="Typeface">
          <window.TweakRadio label="Preset" value={tweaks.fontPreset}
            options={[
              {value:"editorial",label:"Editorial"},
              {value:"modern",   label:"Modern"},
              {value:"bold",     label:"Bold"},
            ]}
            onChange={v => setTweak("fontPreset", v)} />
        </window.TweakSection>
        <window.TweakSection label="Atlas">
          <window.TweakRadio label="Density" value={tweaks.atlasDensity}
            options={[
              {value:"sparse",  label:"Sparse"},
              {value:"balanced",label:"Balanced"},
              {value:"dense",   label:"Dense"},
            ]}
            onChange={v => setTweak("atlasDensity", v)} />
          <window.TweakToggle label="Show grain"
            value={tweaks.showGrain}
            onChange={v => setTweak("showGrain", v)} />
        </window.TweakSection>
      </window.TweaksPanel>
    </div>
  );
}

// NAV-RESTRUCTURE (A1): single source of truth for the primary navigation.
// Both Topbar (horizontal) and Sidebar (vertical) render from this one array so the
// menu structure is edited in exactly one place. Order = the structure agreed 29.06.
// Fields: key (view), labelKey (t.nav.*), Icon, badge(ctx), activeOn(view) override.
// A "__divider__" entry renders as a separator (topbar only; sidebar skips it).
// NAV-RESTRUCTURE v2 (29.06→30.06): "Мои исследования" becomes a dropdown TREE that
// holds Основания + Экосистемы(→общие темы). Foundations & Ecosystems leave the main bar;
// Missions moves into the account (Личный кабинет). Main bar = Atlas · Research▾ · | ·
// Tools · Journal · Quizzes.
function getNavItems(t, ctx) {
  const c = ctx || {};
  return [
    { key: "atlas",       label: t.nav.atlas,       Icon: AtlasIcon },
    { key: "research",    label: t.nav.research,    Icon: GraphIcon, tree: true,
      activeOn: (view) => ["research","foundations","alphabet","lessons","scene","ecosystems","industries"].includes(view) },
    { key: "__divider__" },
    { key: "tools",       label: t.nav.tools,       Icon: ToolIcon,    badge: "57" },
    { key: "journal",     label: t.nav.journal,     Icon: PinIcon,
      badge: (c.savedCount > 0 ? String(c.savedCount) : null) },
    { key: "quizzes",     label: t.nav.quizzes,     Icon: QuizIcon },
  ];
}

function ResearchCatalogPage({ t, locale, group, catalog, onOpenEntry, onBack }) {
  const L = (o) => (o ? (o[locale] || o.en || "") : "");
  const entries = (catalog && catalog[group]) || [];
  const isEcosystems = group === "ecosystems";
  const title = isEcosystems ? t.nav.ecosystems : t.nav.industries;
  const ecosystemLede = {
    en: "TON, Ethereum, Bitcoin and Base — the four ecosystem courses in Luminara.",
    ru: "TON, Ethereum, Bitcoin и Base — четыре экосистемных курса Luminara.",
    uk: "TON, Ethereum, Bitcoin і Base — чотири екосистемні курси Luminara.",
    kk: "TON, Ethereum, Bitcoin және Base — Luminara-ның төрт экожүйелік курсы.",
    uz: "TON, Ethereum, Bitcoin va Base — Luminara’dagi to‘rtta ekotizim kursi.",
    es: "TON, Ethereum, Bitcoin y Base: los cuatro cursos de ecosistemas de Luminara.",
    fr: "TON, Ethereum, Bitcoin et Base : les quatre parcours écosystèmes de Luminara.",
    hy: "TON, Ethereum, Bitcoin և Base՝ Luminara-ի չորս էկոհամակարգային դասընթացները։",
  };
  const lede = isEcosystems ? (ecosystemLede[locale] || ecosystemLede.en) : ({
    en: "Applied fields of the new internet: intelligence, markets, real-world assets and games.",
    ru: "Прикладные индустрии нового интернета: интеллект, рынки, реальные активы и игры.",
    uk: "Прикладні індустрії нового інтернету: інтелект, ринки, реальні активи та ігри.",
    kk: "Жаңа интернеттің қолданбалы салалары: интеллект, нарықтар, нақты активтер және ойындар.",
    uz: "Yangi internetning amaliy sohalari: intellekt, bozorlar, real aktivlar va o‘yinlar.",
    es: "Industrias aplicadas del nuevo internet: inteligencia, mercados, activos reales y juegos.",
    fr: "Les secteurs appliqués du nouvel internet : intelligence, marchés, actifs réels et jeux.",
    hy: "Նոր ինտերնետի կիրառական ոլորտները՝ բանականություն, շուկաներ, իրական ակտիվներ և խաղեր։",
  })[locale] || "";
  return (
    <div className="section-pad fade-in research research-catalog-page">
      <button className="eco-back" onClick={onBack}><span>←</span> {t.nav.research}</button>
      <div className="section-head">
        <div className="kicker">{t.nav.research}</div>
        <h2>{title}</h2>
        <p>{lede}</p>
      </div>
      <div className="research-catalog-list">
        {entries.map((entry, index) => (
          <button className="research-catalog-card" key={entry.id} data-c={entry.id}
          onClick={() => onOpenEntry({ ...entry, group })}>
            <span className="research-catalog-n">{String(index + 1).padStart(2, "0")}</span>
            <span className="research-catalog-title">{L(entry.title)}</span>
            <span className="research-catalog-go">→</span>
          </button>
        ))}
      </div>
    </div>
  );
}

// NAV-RESTRUCTURE v2: the dropdown tree under "Мои исследования".
// Level 1: Основания · Экосистемы. Level 2 (under Экосистемы): each ecosystem.
// Level 3 (under each ecosystem): its общие темы (BLOCK_TOPICS). Click = navigate.
function ResearchNavTree({ t, locale, open, onClose, setView, onOpenChapter, onOpenLesson }) {
  const [ecoOpen, setEcoOpen] = useState_app(true);  // NAV-ECO-DEFAULT-OPEN: expanded by default
  const [foundOpen, setFoundOpen] = useState_app(true); // NAV-FOUNDATIONS-EXPAND: expanded by default
  const [indOpen, setIndOpen] = useState_app(true); // B3 (#44): Industries expanded by default
  const [openEcoId, setOpenEcoId] = useState_app(null);
  const data = (typeof window !== "undefined" && window.LUMINARA_DATA) || {};
  const BLOCK = data.BLOCK_TOPICS || {};
  const CHAPTERS = Array.isArray(data.CHAPTERS) ? data.CHAPTERS : []; // Основания: 8 spine + Ясли + Азбука
  const EXT = (typeof window !== "undefined" && window.LUMINARA_EXTERNAL_THEMES) || [];
  const L = (o) => (o ? (o[locale] || o.en) : "");
  if (!open) return null;

  // The four ecosystems, fixed by id + localized title. We DON'T read the runtime
  // ECOSYSTEMS list here because after the Directus rebuild it gets polluted with
  // individual topics (Что такое TON, Кошелёк, Mini Apps…) — that produced the mixed
  // "каша" in the menu. These five are the canonical ecosystem groups.
  // C4 navigation IA: three top-level content groups — Foundations / Ecosystems / Industries.
  // Ecosystems are the crypto networks (TON, Ethereum, Bitcoin). Industries (Trading, AI, RWA,
  // GameFi) are a SEPARATE agreed group; RWA/GameFi moved here out of the old flat ecosystem
  // list, and Trading + AI are added to the model. Their topic ids/URLs are unchanged so existing
  // routes and progress keys keep working.
  const ECO5 = RESEARCH_CATALOG.ecosystems;
  // Navigation counts are compact numerals for every group. Course hierarchy is
  // communicated by its route and overview, never by a locale-specific unit word.
  const navCountText = (_entry, count) => String(count);
  const INDUSTRIES = RESEARCH_CATALOG.industries;
  // ETH-atlas drives the first 14 Ethereum lessons. Ethereum White Paper is a
  // separate CMS course: render it as the same dedicated bonus card pattern as
  // TON White Paper, not as a fifteenth numbered lesson or an ecosystem card.
  const ethAtlas = CHAPTERS.find((c) => c.key === "eth-atlas") || {};
  const ethWhitePaper = EXT.find((x) => x && x.id === "ethereum-whitepaper");
  const ethWhitePaperCourse = ethWhitePaper && Array.isArray(ethWhitePaper.scenes) && ethWhitePaper.scenes.length
    ? { key: "ethereum-whitepaper", title: ethWhitePaper.ecosystem && (ethWhitePaper.ecosystem.titleMl || ethWhitePaper.ecosystem.title), scenes: ethWhitePaper.scenes.length }
    : null;
  const ethLessons = ethAtlas.lessons || [];
  const ethCourse = { ...ethAtlas, lessons: ethLessons, bonusCourse: ethWhitePaperCourse };
  // Base is a parent course whose lessons are stored under ten chapter topics. Keep
  // the parent visible as one ecosystem entry: it opens the course overview, while
  // every leaf routes to its real chapter key.
  const baseChapters = EXT.filter((x) => x && /^base-ch\d{2}$/.test(x.id || ""))
    .sort((a, b) => String(a.id).localeCompare(String(b.id)));
  // Subtopics for an ecosystem: prefer BLOCK_TOPICS labels; fall back to scene titles.
  const subsOf = (id) => {
    // Ethereum = the eth-atlas course; its subtopics are the 14 lessons (opened via onOpenLesson).
    // Resolve to a STRING here through the shared resolver, passing the index so a lesson with no
    // localized title becomes "Lesson NN" (localized) — never a bare number, never Russian in a
    // non-RU UI (C1 nav-tree fix: previously returned the raw {ru:...} object and the leaf render
    // fell back to Russian / showed only the number).
    if (id === "ethereum") {
      const res = (typeof window !== "undefined" && window.resolveLocalizedTitle) || ((t) => (t && (t[locale] || t.en)) || "");
      return ethLessons.map((l, i) => res(l.title, locale, { index: i }));
    }
    if (id === "base") {
      const res = (typeof window !== "undefined" && window.resolveLocalizedTitle) || ((v) => (v && typeof v === "object" ? (v[locale] || v.en || v.ru || "") : (v || "")));
      return baseChapters.map((chapter, i) => {
        const eco = chapter.ecosystem || {};
        return res(eco.titleMl || eco.title, locale, { index: i }) || `Base · ${String(i + 1).padStart(2, "0")}`;
      });
    }
    const topicId = (typeof window !== "undefined" && window.LUM_ROUTE && window.LUM_ROUTE.canonicalTopic)
      ? window.LUM_ROUTE.canonicalTopic(id) : id;
    const b = BLOCK[topicId] || BLOCK[id];
    if (Array.isArray(b) && b.length) return b;
    const th = EXT.find((x) => x.id === topicId);
    if (th && Array.isArray(th.scenes) && th.scenes.length) {
      return th.scenes.map((sc) => (sc && sc.title) ? (sc.title[locale] || sc.title.en || "") : "");
    }
    return [];
  };
  // Nav-tree route for an ecosystem label. Ethereum → open the eth-atlas chapter (full lesson
  // list); everything else → its research topic.
  const openEcosystem = (id) => {
    if (id === "ethereum") {
      const eth = ethCourse;
      if (eth && onOpenChapter) { go(() => onOpenChapter(eth)); return; }
    }
    openTopic(id);
  };
  // Nav-tree route for an ecosystem leaf (subtopic). Ethereum leaves are eth-atlas lessons → open
  // the specific lesson; everything else → the research topic at that subtopic index.
  const openEcosystemLeaf = (id, i) => {
    if (id === "ethereum") {
      const les = ethLessons[i];
      if (les && onOpenLesson) { go(() => onOpenLesson(les)); return; }
      openEcosystem(id); return;
    }
    if (id === "base") {
      const chapter = baseChapters[i];
      openTopic(chapter ? chapter.id : `base-ch${String(i + 1).padStart(2, "0")}`);
      return;
    }
    openTopic(id, i);
  };

  const go = (fn) => { fn(); onClose && onClose(); };
  const openFoundations = () => go(() => setView("foundations"));
  // "Экосистемы": navigate to the section AND expand the list (single intuitive action).
  const ecosystemsClick = () => {
    setEcoOpen(true);
    setView("ecosystems");
    onClose && onClose();
  };
  // Open an ecosystem topic (and optionally a specific scene) inside research.
  const openTopic = (id, subIdx) => go(() => {
    const topicId = (typeof window !== "undefined" && window.LUM_ROUTE && window.LUM_ROUTE.canonicalTopic)
      ? window.LUM_ROUTE.canonicalTopic(id) : id;
    setView("research");
    setTimeout(() => {
      if (typeof window !== "undefined") {
        if (window.LUM_ROUTE) {
          window.LUM_ROUTE.set("t", topicId);
          window.LUM_ROUTE.set("s", (subIdx != null ? String(subIdx) : null));
          // QA-004: clear stale Foundations scene params so a previously opened lesson
          // (e.g. c=eth_01) can't bleed into a research topic route via the scene restorer.
          window.LUM_ROUTE.set("c", null);
          window.LUM_ROUTE.set("l", null);
        }
        try { window.dispatchEvent(new CustomEvent("lum:open-topic", { detail: { id: topicId, sub: subIdx } })); } catch (e) {}
      }
    }, 0);
  });

  // The product catalogue always exposes the four approved industry courses.
  // Content access is still resolved by the existing server-backed readers.
  const visibleIndustries = INDUSTRIES;

  return (
    <div className="nav-tree" role="menu">
      {/* Основания: [>] expands the chapter list · word → #/foundations (NAV-FOUNDATIONS-EXPAND) */}
      <div className="nav-tree-eco">
        <div className="nav-tree-row lvl1 hasrow">
          <button className={"nav-chev-btn" + (foundOpen ? " open" : "")}
                  onClick={() => setFoundOpen(o => !o)}
                  aria-expanded={foundOpen} aria-label="expand">
            <span className="nav-chev">▸</span>
          </button>
          <button className="nav-tree-label" onClick={openFoundations}>
            <BookIcon /> <span>{t.nav.foundations}</span>
          </button>
        </div>
        {foundOpen && CHAPTERS.filter((chap) => chap.key !== "eth-atlas").map((chap) => (
          <button key={chap.key} className="nav-tree-row lvl2"
                  onClick={() => go(() => onOpenChapter && onOpenChapter(chap))}>
            <span className="nav-chev-slot" />
            <span className="nav-tree-sub">{L(chap.title)}</span>
          </button>
        ))}
      </div>

      <div className="nav-tree-eco">
        {/* Экосистемы: [>] expands · word → #/ecosystems */}
        <div className="nav-tree-row lvl1 hasrow">
          <button className={"nav-chev-btn" + (ecoOpen ? " open" : "")}
                  onClick={() => setEcoOpen(o => !o)}
                  aria-expanded={ecoOpen} aria-label="expand">
            <span className="nav-chev">▸</span>
          </button>
          <button className="nav-tree-label" onClick={ecosystemsClick}>
            <span>{t.nav.ecosystems}</span>
          </button>
        </div>

        {ecoOpen && ECO5.map((e) => {
          const subs = subsOf(e.id);
          const isOpen = openEcoId === e.id;
          return (
            <div key={e.id} className="nav-tree-eco">
              {/* ecosystem L2: [>] expands scenes · word → #/research?t=<id> */}
              <div className="nav-tree-row lvl2 hasrow">
                {subs.length ? (
                  <button className={"nav-chev-btn" + (isOpen ? " open" : "")}
                          onClick={() => setOpenEcoId(isOpen ? null : e.id)}
                          aria-expanded={isOpen} aria-label="expand">
                    <span className="nav-chev">▸</span>
                  </button>
                ) : <span className="nav-chev-slot" />}
                <button className="nav-tree-label" onClick={() => openEcosystem(e.id)}>
                  <span>{L(e.title)}</span>
  {subs.length ? <span className="nav-tree-count">{navCountText(e, subs.length)}</span> : null}
                </button>
              </div>
              {/* L3: scenes (leaf) → #/research?t=<id>&s=<index> */}
              {isOpen && subs.map((sub, i) => (
                <button key={i} className="nav-tree-row lvl3" onClick={() => openEcosystemLeaf(e.id, i)}>
                  <span className="nav-chev-slot" />
                  <span className="nav-tree-n">{String(i + 1).padStart(2, "0")}</span>
                  <span className="nav-tree-sub">{typeof sub === "string" ? sub : L(sub)}</span>
                </button>
              ))}
            </div>
          );
        })}
      </div>

      {/* C4: Industries (Trading / AI / RWA / GameFi) — a third top-level group. Rendered ONLY
          when at least one industry is backed by real content; while all four are content-team
          handoffs the whole group stays hidden (no empty header, no fake "coming soon"). Access
          level for these topics comes from server/content metadata and is enforced server-side;
          the tree is navigation only. */}
      {visibleIndustries.length ? (
        <div className="nav-tree-eco">
          <div className="nav-tree-row lvl1 hasrow">
            <button className={"nav-chev-btn" + (indOpen ? " open" : "")}
                    onClick={() => setIndOpen((o) => !o)}
                    aria-expanded={indOpen} aria-label="expand">
              <span className="nav-chev">▸</span>
            </button>
            <button className="nav-tree-label" onClick={() => go(() => setView("industries"))}>
              <span>{t.nav.industries}</span>
            </button>
          </div>
          {indOpen && visibleIndustries.map((e) => {
            const subs = subsOf(e.id);
            const isOpen = openEcoId === "ind:" + e.id;
            return (
              <div key={e.id} className="nav-tree-eco">
                <div className="nav-tree-row lvl2 hasrow">
                  {subs.length ? (
                    <button className={"nav-chev-btn" + (isOpen ? " open" : "")}
                            onClick={() => setOpenEcoId(isOpen ? null : "ind:" + e.id)}
                            aria-expanded={isOpen} aria-label="expand">
                      <span className="nav-chev">▸</span>
                    </button>
                  ) : <span className="nav-chev-slot" />}
                  <button className="nav-tree-label" onClick={() => openTopic(e.id)}>
                    <span>{L(e.title)}</span>
  {subs.length ? <span className="nav-tree-count">{navCountText(e, subs.length)}</span> : null}
                  </button>
                </div>
                {isOpen && subs.map((sub, i) => (
                  <button key={i} className="nav-tree-row lvl3" onClick={() => openTopic(e.id, i)}>
                    <span className="nav-chev-slot" />
                    <span className="nav-tree-n">{String(i + 1).padStart(2, "0")}</span>
                    <span className="nav-tree-sub">{typeof sub === "string" ? sub : L(sub)}</span>
                  </button>
                ))}
              </div>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

// SITE-SEARCH (client-side, variant A): searches loaded content — research scenes
// (EXTERNAL_THEMES), foundations chapters (CHAPTERS), and White Paper modules
// (LUMINARA_WHITEPAPER). Results link to the scene via its unique URL. No backend.
function SearchBox({ t, locale, setView }) {
  const [q, setQ] = useState_app("");
  const [open, setOpen] = useState_app(false);
  const boxRef = React.useRef(null);
  const L = (o) => (o && typeof o === "object") ? (o[locale] || o.en || o.ru || "") : (o || "");

  useEffect_app(() => {
    if (!open) return;
    const onDoc = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open]);

  // Build a flat index from whatever content is loaded.
  const buildIndex = () => {
    const idx = [];
    const D = (typeof window !== "undefined" && window.LUMINARA_DATA) || {};
    const EXT = (typeof window !== "undefined" && window.LUMINARA_EXTERNAL_THEMES) || [];
    // research scenes
    EXT.forEach((th) => {
      (th.scenes || []).forEach((sc, i) => {
        idx.push({
          kind: "scene", topic: th.id, sub: i,
          title: L(sc.title), body: L(sc.body), extra: L(sc.insight),
          tags: Array.isArray(sc.tags) ? sc.tags.join(" ") : "",
          label: L(sc.title), crumb: (th.title ? L(th.title) : th.id),
        });
      });
    });
    // foundations chapters (each chapter = a topic; scenes inside if present via EXTERNAL already)
    (D.CHAPTERS || []).forEach((c) => {
      idx.push({
        kind: "foundations", topic: c.key,
        title: L(c.title), body: L(c.blurb) || "", extra: "",
        tags: "", label: L(c.title), crumb: (t.nav ? t.nav.foundations : "Foundations"),
      });
    });
    // white paper modules
    const WP = (typeof window !== "undefined" && window.LUMINARA_WHITEPAPER) || null;
    const wpArr = Array.isArray(WP) ? WP : (WP && WP.data) ? WP.data : [];
    wpArr.forEach((m) => {
      idx.push({
        kind: "wp", n: m.n,
        title: L(m.title), body: L(m.interpretation) || L(m.translation) || "", extra: L(m.intro_ru) || "",
        tags: Array.isArray(m.tags) ? m.tags.join(" ") : "",
        label: L(m.title), crumb: "White Paper",
      });
    });
    return idx;
  };

  const results = React.useMemo(() => {
    const query = q.trim().toLowerCase();
    if (query.length < 2) return [];
    const idx = buildIndex();
    const scored = [];
    idx.forEach((r) => {
      const inTitle = (r.title || "").toLowerCase().indexOf(query);
      const inTags = (r.tags || "").toLowerCase().indexOf(query);
      const inBody = (r.body || "").toLowerCase().indexOf(query);
      const inExtra = (r.extra || "").toLowerCase().indexOf(query);
      if (inTitle < 0 && inTags < 0 && inBody < 0 && inExtra < 0) return;
      // score: title hit > tag hit > body hit
      const score = (inTitle >= 0 ? 100 - inTitle : 0) + (inTags >= 0 ? 40 : 0) + (inBody >= 0 ? 10 : 0);
      // snippet around first body hit
      let snip = "";
      const hay = r.body || r.extra || "";
      const pos = hay.toLowerCase().indexOf(query);
      if (pos >= 0) {
        const start = Math.max(0, pos - 40);
        snip = (start > 0 ? "…" : "") + hay.slice(start, pos + query.length + 60).replace(/\n+/g, " ") + "…";
      }
      scored.push({ r, score, snip });
    });
    scored.sort((a, b) => b.score - a.score);
    return scored.slice(0, 12);
  }, [q, locale]);

  const goResult = (r) => {
    setOpen(false); setQ("");
    if (r.kind === "scene") {
      setView("research");
      setTimeout(() => {
        if (typeof window !== "undefined") {
          if (window.LUM_ROUTE) { window.LUM_ROUTE.set("t", r.topic); window.LUM_ROUTE.set("s", String(r.sub)); }
          try { window.dispatchEvent(new CustomEvent("lum:open-topic", { detail: { id: r.topic, sub: r.sub } })); } catch (e) {}
        }
      }, 0);
    } else if (r.kind === "foundations") {
      setView("foundations");
    } else if (r.kind === "wp") {
      setView("research");
      setTimeout(() => {
        if (typeof window !== "undefined" && window.LUM_ROUTE) { window.LUM_ROUTE.set("wp", "1"); }
      }, 0);
    }
  };

  const ph = (t.search || "Search…");
  const noRes = { en: "Nothing found", ru: "Ничего не найдено", uk: "Нічого не знайдено",
    kk: "Ештеңе табылмады", uz: "Hech narsa topilmadi", es: "Nada encontrado",
    fr: "Rien trouvé", hy: "Ոչինչ չգտնվեց" };

  return (
    <div className="search search-live" ref={boxRef}>
      <SearchIcon />
      <input className="search-input" value={q}
             onChange={(e) => { setQ(e.target.value); setOpen(true); }}
             onFocus={() => { if (q.trim().length >= 2) setOpen(true); }}
             placeholder={ph} aria-label={ph} />
      {open && q.trim().length >= 2 && (
        <div className="search-drop" role="listbox">
          {results.length === 0 ? (
            <div className="search-empty">{noRes[locale] || noRes.en}</div>
          ) : results.map((it, i) => (
            <button key={i} className="search-res" onClick={() => goResult(it.r)}>
              <div className="search-res-top">
                <span className="search-res-title">{it.r.label}</span>
                <span className="search-res-crumb">{it.r.crumb}</span>
              </div>
              {it.snip ? <div className="search-res-snip">{it.snip}</div> : null}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function Topbar({ t, view, setView, lang, setLang, theme, setTheme, palette, setPalette, accountInitial, isAdmin, onAdmin, onToggleNav, onOpenChapter, onOpenLesson }) {
  const items = getNavItems(t, {}).map((it) =>
    it.key === "__divider__" ? ["__divider__", null, false] : [it.key, it.label, !!it.tree]
  );
  const [treeOpen, setTreeOpen] = useState_app(false);
  const treeRef = React.useRef(null);
  useEffect_app(() => {
    if (!treeOpen) return;
    // MOBILE-NAV-TREE: on mobile the topbar nav is overflow-x:auto (horizontal scroll), which
    // clips the absolutely-positioned dropdown → "Мои исследования" opened but was invisible/
    // untappable. On mobile the tree is position:fixed (escapes the clip); anchor it just under
    // the topbar by measuring the topbar height into a CSS var (topbar wraps to 2 rows, height
    // is dynamic, so a hardcoded top would be fragile).
    try {
      const tb = document.querySelector(".topbar");
      if (tb) document.documentElement.style.setProperty("--nav-tree-top", tb.offsetHeight + "px");
    } catch (e) {}
    const onDoc = (e) => { if (treeRef.current && !treeRef.current.contains(e.target)) setTreeOpen(false); };
    document.addEventListener("mousedown", onDoc);
    const onEsc = (e) => { if (e.key === "Escape") setTreeOpen(false); };
    document.addEventListener("keydown", onEsc);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onEsc); };
  }, [treeOpen]);
  const palettes = [
    ["violet", "Violet"],
    ["cobalt", "Cobalt"],
    ["ember",  "Ember"],
    ["forest", "Forest"],
    ["crimson","Crimson"],
  ];
  return (
    <header className="topbar" data-screen-label="00 Top bar">
      <button className="nav-burger" aria-label="Меню" onClick={onToggleNav}>
        <svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
          <path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"/>
        </svg>
      </button>
      <div className="brand" onClick={() => setView("atlas")} role="button" tabIndex={0}
           title="Luminara — на главную"
           onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setView("atlas"); } }}>
        <span className="mark"><LuminaraMark /></span>
        <span className="word"><b>Luminara</b></span>
        {/* C3: Beta/Staging word is localized (t.confidential); the version NUMBER is the same for
            every locale and comes from server runtime config, never a hardcoded "v1.2". */}
        <span className="ver">{t.confidential}{APP_VERSION ? ` · ${APP_VERSION}` : ""}</span>
      </div>

      <div className="center">
        <nav>
          {items.map(([k, label, isTree]) => {
            if (k === "__divider__") return <span key="div" className="nav-divider" aria-hidden="true" />;
            if (isTree) {
              const active = ["research","foundations","alphabet","lessons","scene","ecosystems","industries"].includes(view);
              return (
                <span key={k} className="nav-tree-wrap" ref={treeRef}>
                  <button className={(active ? "active " : "") + "nav-has-tree" + (treeOpen ? " tree-open" : "")}
                          onClick={() => setTreeOpen(o => !o)} aria-expanded={treeOpen}>
                    <span className="dot" /> {label} <span className="nav-caret">▾</span>
                  </button>
                  <ResearchNavTree t={t} locale={lang} open={treeOpen}
                                   onClose={() => setTreeOpen(false)} setView={setView}
                                   onOpenChapter={onOpenChapter} onOpenLesson={onOpenLesson} />
                </span>
              );
            }
            const active = view === k;
            return (
              <button key={k} className={active ? "active" : ""} onClick={() => setView(k)}>
                <span className="dot" /> {label}
              </button>
            );
          })}
        </nav>
        <SearchBox t={t} locale={lang} setView={setView} />
      </div>

      <div className="right">
        <div className="palette-switch" role="group" aria-label="Color palette">
          {palettes.map(([p, name]) => (
            <button key={p}
                    className={"sw" + (palette === p ? " active" : "")}
                    data-p={p}
                    title={name}
                    aria-label={name}
                    onClick={() => setPalette(p)} />
          ))}
        </div>
        <button className="icon-btn"
                aria-label="Toggle theme"
                onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
          {theme === "dark" ? <SunIcon /> : <MoonIcon />}
        </button>
        <NotificationsBell t={t} locale={lang} />
        <LanguageSwitcher lang={lang} setLang={setLang} />
        {isAdmin && (
          <button className={"icon-btn" + (view === "admin" ? " active" : "")}
                  onClick={onAdmin}
                  title="Модерация" aria-label="Moderation">
            <svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
              <path d="M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round"/>
            </svg>
          </button>
        )}
        <button className={"avatar" + (view === "account" ? " active" : "")}
                onClick={() => setView("account")}
                title="Кабинет" aria-label="Account">{accountInitial || "N"}</button>
      </div>
    </header>
  );
}

function LanguageSwitcher({ lang, setLang }) {
  const [open, setOpen] = useState_app(false);
  const ref = React.useRef(null);
  const cur = window.LUMINARA_I18N[lang];
  useEffect_app(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", (e) => { if (e.key === "Escape") setOpen(false); });
    return () => document.removeEventListener("mousedown", onDoc);
  }, [open]);
  return (
    <div className="lang" ref={ref}>
      <button className="pill" onClick={() => setOpen(o => !o)}>
        <GlobeIcon />
        <span>{cur.code}</span>
        <span style={{ color: 'var(--text-dim)' }}>▾</span>
      </button>
      {open && (
        <div className="menu">
          {Object.entries(window.LUMINARA_I18N).map(([k, l]) => (
            <button key={k}
                    className={k === lang ? "current" : ""}
                    onClick={() => { setLang(k); setOpen(false); }}>
              <span className="code">{l.code}</span>
              <span>{l.name}</span>
              {k === lang && <span style={{ color: 'var(--accent)' }}>●</span>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function LocalePrompt({ locale, onChoose }) {
  const title = ({
    en: "Choose your language", ru: "Выберите язык", uk: "Оберіть мову", kk: "Тілді таңдаңыз",
    uz: "Tilni tanlang", es: "Elige tu idioma", fr: "Choisissez votre langue", hy: "Ընտրեք լեզուն",
  })[locale] || "Choose your language";
  return (
    <div className="locale-prompt" role="dialog" aria-label={title}>
      <span>{title}</span>
      <div className="locale-prompt-options">
        {LUM_SUPPORTED_LOCALES.map((key) => (
          <button key={key} type="button" onClick={() => onChoose(key)}>
            {(window.LUMINARA_I18N[key] && window.LUMINARA_I18N[key].name) || key.toUpperCase()}
          </button>
        ))}
      </div>
    </div>
  );
}

function Sidebar({ t, view, setView, savedCount, learning, open, onClose, selectedNode, onPickNode }) {
  const sidebarRef = useRef_app(null);
  const closeRef = useRef_app(null);
  useEffect_app(() => {
    if (!open || !sidebarRef.current) return;
    sidebarRef.current.scrollTop = 0;
    requestAnimationFrame(() => { try { closeRef.current && closeRef.current.focus({ preventScroll: true }); } catch (e) {} });
  }, [open]);
  const pct = Math.max(0, Math.min(100, Number(learning && learning.percent) || 0));
  const items = getNavItems(t, { savedCount })
    .filter((it) => it.key !== "__divider__")
    .map((it) => [it.key, it.label, it.Icon,
      (typeof it.badge === "function" ? it.badge({ savedCount }) : (it.badge || null)),
      it.activeOn || null]);
  const colorOf = (g) =>
    g === "ethereum" ? "var(--c-eth)"
    : g === "ton" ? "var(--c-ton)"
    : g === "bitcoin" ? "var(--c-btc)"
    : g === "rwa" ? "var(--c-rwa)"
    : g === "gamefi" ? "var(--c-game)"
    : "var(--c-foundations)";
  return (
    <aside className="sidebar" ref={sidebarRef}>
      <button ref={closeRef} className="sidebar-close" type="button" aria-label="Close navigation" onClick={onClose}>✕</button>
      <div className="sec">{t.sectionLabel}</div>
      {items.map(([k, label, Icon, badge, activeOn]) => {
        const active = activeOn ? activeOn(view) : view === k;
        return (
          <div key={k} className={`side-item ${active ? "active" : ""}`} onClick={() => setView(k)}>
            <span className="glyph"><Icon /></span>
            <span>{label}</span>
            {badge && <span className="badge">{badge}</span>}
          </div>
        );
      })}

      <div className="progress-card">
        <div className="h">{t.progress}</div>
        <div className="v">{pct}<span style={{ fontSize: 18, color: 'var(--text-muted)' }}>%</span></div>
        <div className="bar"><i style={{ right: 'auto', width: pct + '%' }} /></div>
        <div className="note">{learning ? `${learning.completedScenes} / ${learning.totalScenes} · ${t.progressNote}` : t.progressNote}</div>
      </div>

      <div className="mini-map">
        <div className="h">{t.minimap}</div>
        <div className="canvas">
          {window.LUMINARA_DATA.NODES.map(n => {
            const isSel = n.id === selectedNode;
            const size = Math.max(4, n.r / 6);
            // нормализуем y (исходный диапазон ~22..80) в 10..90, чтобы точки не сбивались в центр
            const ny = 10 + ((n.y - 22) / (80 - 22)) * 80;
            return (
              <button key={n.id}
                      className={"dot" + (isSel ? " sel" : "")}
                      title={(n.title && (n.title[t.code?.toLowerCase?.()] || n.title.en)) || n.id}
                      aria-label={(n.title && (n.title[t.code?.toLowerCase?.()] || n.title.en)) || n.id}
                      onClick={() => onPickNode && onPickNode(n.id)}
                      style={{
                        left: `${n.x}%`, top: `${Math.max(4, Math.min(96, ny))}%`,
                        color: colorOf(n.group),
                        width: size, height: size,
                      }} />
            );
          })}
        </div>
      </div>
    </aside>
  );
}

function QuestionOfDay({ t, locale, onJump }) {
  // Демо: вопрос дня. В проде — из админки/календаря (365 дней) + авто-события + AI.
  // nodeId — узел в атласе, к которому ведёт вопрос (TICKET-014).
  const QOD = {
    en: { tag: "Question of the day", date: "Bitcoin Pizza Day", nodeId: "bitcoin",
          q: "If Bitcoin can buy pizza, why call it 'digital gold'?",
          hint: "Answer in a line — or just say it out loud." },
    ru: { tag: "Вопрос дня", date: "Bitcoin Pizza Day", nodeId: "bitcoin",
          q: "Если за биткоин можно купить пиццу, почему его зовут «цифровым золотом»?",
          hint: "Ответь одной строкой — или просто скажи голосом." },
    uk: { tag: "Питання дня", date: "Bitcoin Pizza Day", nodeId: "bitcoin",
          q: "Якщо за біткоїн можна купити піцу, чому його звуть «цифровим золотом»?",
          hint: "Відповідай одним рядком — або просто скажи голосом." },
    kk: { tag: "Күн сұрағы", date: "Bitcoin Pizza Day", nodeId: "bitcoin",
          q: "Биткоинге пицца сатып алуға болса, неге оны «цифрлық алтын» дейді?",
          hint: "Бір жолмен жауап бер — немесе дауыстап айт." },
    uz: { tag: "Kun savoli", date: "Bitcoin Pizza Day", nodeId: "bitcoin",
          q: "Agar bitkoinga pitsa sotib olish mumkin boʻlsa, nega uni «raqamli oltin» deyishadi?",
          hint: "Bir qatorda javob ber — yoki ovozli ayt." },
    es: { tag: "Pregunta del día", date: "Bitcoin Pizza Day", nodeId: "bitcoin",
          q: "Si con Bitcoin puedes comprar pizza, ¿por qué lo llaman «oro digital»?",
          hint: "Responde en una línea — o simplemente dilo en voz alta." },
    fr: { tag: "Question du jour", date: "Bitcoin Pizza Day", nodeId: "bitcoin",
          q: "Si le Bitcoin peut acheter une pizza, pourquoi l'appeler « or numérique » ?",
          hint: "Réponds en une ligne — ou dis-le simplement à voix haute." },
    hy: { tag: "Օրվա հարցը", date: "Bitcoin Pizza Day", nodeId: "bitcoin",
          q: "Եթե Bitcoin-ով կարելի է պիցցա գնել, ինչու՞ են այն անվանում «թվային ոսկի»։",
          hint: "Պատասխանիր մեկ տողով՝ կամ պարզապես ասա ձայնով։" },
  };
  const d = QOD[locale] || QOD.en;
  const [text, setText] = useState_app("");
  const [sent, setSent] = useState_app(false);
  const canJump = !!(onJump && d.nodeId);
  return (
    <div className="qod">
      <div className="qod-head">
        <span className="qod-tag">◆ {d.tag}</span>
        <span className="qod-date">{d.date}</span>
      </div>
      <div className={"qod-q" + (canJump ? " qod-q-link" : "")}
           onClick={canJump ? () => onJump(d.nodeId) : undefined}
           title={canJump ? (t.qodOpenNode || "Open in atlas") : undefined}>
        {d.q}{canJump && <span className="qod-q-arrow"> ↗</span>}
      </div>
      {!sent ? (
        <div className="qod-answer">
          <input className="qod-input" value={text}
                 onChange={e => setText(e.target.value)}
                 placeholder={d.hint} />
          <button className="qod-mic" title="Voice" aria-label="Voice" style={{ display: "none" }}>
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none">
              <rect x="9" y="3" width="6" height="11" rx="3" fill="currentColor"/>
              <path d="M6 11a6 6 0 0 0 12 0M12 17v4M9 21h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
            </svg>
          </button>
          <button className="qod-send" disabled={!text.trim()} onClick={() => setSent(true)}>→</button>
        </div>
      ) : (
        <div className="qod-done">✓ {t.qodThanks || "Heard — your thinking matters."}</div>
      )}
    </div>
  );
}

function CryptoCalendar({ t, locale }) {
  // Крипто-даты. m=месяц(1-12), day=число. В проде — из админки (Руслан дольёт даты).
  // Недельный/ближайший формат (TICKET-015): сортируем по близости к сегодня, показываем лентой с прокруткой.
  const DATES = [
    { m: 1,  day: 3,  key: "genesis",  icon: "₿", year: "2009",
      label: { en: "Bitcoin Genesis Block", ru: "Генезис-блок Bitcoin", uk: "Генезис-блок Bitcoin", kk: "Bitcoin генезис-блогы", uz: "Bitcoin Genesis bloki", es: "Bloque génesis de Bitcoin", fr: "Bloc genèse de Bitcoin", hy: "Bitcoin-ի Genesis բլոկ" } },
    { m: 1,  day: 30, key: "uni",      icon: "🦄", year: "2018",
      label: { en: "Uniswap founded", ru: "Основание Uniswap", uk: "Заснування Uniswap", kk: "Uniswap негізі", uz: "Uniswap asoslandi", es: "Fundación de Uniswap", fr: "Création d'Uniswap", hy: "Uniswap-ի հիմնադրում" } },
    { m: 3,  day: 14, key: "pi",       icon: "π", year: "—",
      label: { en: "Pi Day · math & crypto", ru: "День числа Пи · математика и крипто", uk: "День числа Пі · математика і крипто", kk: "Пи күні · математика мен крипто", uz: "Pi kuni · matematika va kripto", es: "Día de Pi · matemáticas y cripto", fr: "Jour de Pi · maths et crypto", hy: "Pi-ի օր · մաթեմատիկա և կրիպտո" } },
    { m: 4,  day: 11, key: "halving",  icon: "⧗", year: "2024",
      label: { en: "Last BTC halving", ru: "Последний халвинг BTC", uk: "Останній халвінг BTC", kk: "Соңғы BTC халвингі", uz: "Soʻnggi BTC halving", es: "Último halving de BTC", fr: "Dernier halving du BTC", hy: "BTC-ի վերջին halving" } },
    { m: 5,  day: 22, key: "pizza",    icon: "🍕", year: "2010",
      label: { en: "Bitcoin Pizza Day", ru: "Bitcoin Pizza Day", uk: "Bitcoin Pizza Day", kk: "Bitcoin Pizza Day", uz: "Bitcoin Pizza Day", es: "Día de la pizza de Bitcoin", fr: "Bitcoin Pizza Day", hy: "Bitcoin-ի պիցցայի օր" } },
    { m: 5,  day: 23, key: "uniid",    icon: "🦄", year: "2024",
      label: { en: "Uniswap v4 reveal", ru: "Анонс Uniswap v4", uk: "Анонс Uniswap v4", kk: "Uniswap v4 жариялауы", uz: "Uniswap v4 e'loni", es: "Presentación de Uniswap v4", fr: "Révélation d'Uniswap v4", hy: "Uniswap v4-ի ներկայացում" } },
    { m: 6,  day: 30, key: "oneinch",  icon: "🦅", year: "2019",
      label: { en: "1inch founded", ru: "Основание 1inch", uk: "Заснування 1inch", kk: "1inch негізі", uz: "1inch asoslandi", es: "Fundación de 1inch", fr: "Création de 1inch", hy: "1inch-ի հիմնադրում" } },
    { m: 7,  day: 30, key: "eth",      icon: "Ξ", year: "2015",
      label: { en: "Ethereum launch", ru: "Запуск Ethereum", uk: "Запуск Ethereum", kk: "Ethereum іске қосылды", uz: "Ethereum ishga tushdi", es: "Lanzamiento de Ethereum", fr: "Lancement d'Ethereum", hy: "Ethereum-ի մեկնարկ" } },
    { m: 8,  day: 31, key: "ton",      icon: "💎", year: "2021",
      label: { en: "TON open network", ru: "Открытие сети TON", uk: "Відкриття мережі TON", kk: "TON желісі ашылды", uz: "TON tarmogʻi ochildi", es: "Red abierta TON", fr: "Réseau ouvert TON", hy: "TON բաց ցանց" } },
    { m: 9,  day: 15, key: "merge",    icon: "⬡", year: "2022",
      label: { en: "The Merge (PoS)", ru: "The Merge (переход на PoS)", uk: "The Merge (перехід на PoS)", kk: "The Merge (PoS-ке көшу)", uz: "The Merge (PoS-ga oʻtish)", es: "The Merge (PoS)", fr: "The Merge (PoS)", hy: "The Merge (PoS)" } },
    { m: 10, day: 31, key: "wp",       icon: "📄", year: "2008",
      label: { en: "Bitcoin whitepaper", ru: "Bitcoin whitepaper", uk: "Bitcoin whitepaper", kk: "Bitcoin whitepaper", uz: "Bitcoin whitepaper", es: "Whitepaper de Bitcoin", fr: "Whitepaper de Bitcoin", hy: "Bitcoin-ի whitepaper" } },
    { m: 11, day: 30, key: "ai",       icon: "✦", year: "2022",
      label: { en: "ChatGPT launch · AI era", ru: "Запуск ChatGPT · эра AI", uk: "Запуск ChatGPT · ера AI", kk: "ChatGPT іске қосылды · AI дәуірі", uz: "ChatGPT ishga tushdi · AI davri", es: "Lanzamiento de ChatGPT · era de la IA", fr: "Lancement de ChatGPT · ère de l'IA", hy: "ChatGPT-ի մեկնարկ · ԱԲ դարաշրջան" } },
    { m: 12, day: 1,  key: "arb",      icon: "🔷", year: "2021",
      label: { en: "Arbitrum One mainnet", ru: "Запуск Arbitrum One", uk: "Запуск Arbitrum One", kk: "Arbitrum One іске қосылды", uz: "Arbitrum One ishga tushdi", es: "Mainnet de Arbitrum One", fr: "Mainnet d'Arbitrum One", hy: "Arbitrum One mainnet" } },
  ];
  const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
  const titles = {
    en: "Crypto calendar", ru: "Календарь крипто-дат", uk: "Календар крипто-дат", kk: "Крипто күнтізбесі", uz: "Kripto taqvimi", es: "Calendario cripto", fr: "Calendrier crypto", hy: "Կրիպտո օրացույց" };
  const todayLbl = { en: "today", ru: "сегодня", uk: "сьогодні", kk: "бүгін", uz: "bugun", es: "hoy", fr: "aujourd'hui", hy: "այսօր" };
  const inDays = (n) => ({
    en: `in ${n}d`, ru: `через ${n} дн`, uk: `за ${n} дн`, kk: `${n} күнде`, uz: `${n} kun`,
  });
  const L = (o) => o[locale] || o.en;

  // External theme calendar entries (loaded content) → same shape as DATES
  const ext = (window.LUMINARA_DATA && window.LUMINARA_DATA.EXTERNAL) || {};
  const extDates = [];
  Object.keys(ext).forEach(tid => {
    (ext[tid].calendar || []).forEach((c, idx) => {
      const mm = (c.date || "").split("-");
      if (mm.length === 2) {
        extDates.push({
          m: parseInt(mm[0], 10), day: parseInt(mm[1], 10),
          key: "ext-" + tid + "-" + idx, icon: c.icon || "◆",
          label: c.event || {},
        });
      }
    });
  });
  const ALL_DATES = DATES.concat(extDates);

  // ближайшие события от сегодня (в пределах года вперёд)
  const now = new Date();
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  const withDelta = ALL_DATES.map(item => {
    let next = new Date(today.getFullYear(), item.m - 1, item.day);
    if (next < today) next = new Date(today.getFullYear() + 1, item.m - 1, item.day);
    const days = Math.round((next - today) / 86400000);
    return { ...item, days };
  }).sort((a, b) => a.days - b.days);

  return (
    <div className="cryptocal">
      <div className="cc-head">
        <span className="cc-tag">▦ {titles[locale] || titles.en}</span>
      </div>
      <div className="cc-list cc-scroll">
        {withDelta.map(item => (
          <div className={"cc-item" + (item.days === 0 ? " cc-today" : "")} key={item.key}>
            <span className="cc-date">{item.day} {MONTHS[item.m - 1]}</span>
            <span className="cc-icon">{item.icon}</span>
            <span className="cc-label">{L(item.label)}</span>
            <span className="cc-when">{item.days === 0 ? L(todayLbl) : L(inDays(item.days))}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function HeroAtlas({ t, locale, selected, onSelect, onCta, onHowItWorks, completed, onComplete, onJump, learningProgressPct }) {
  const total = window.LUMINARA_DATA.NODES.length;
  const done = completed ? completed.size : 0;
  const pct = Number.isFinite(learningProgressPct) ? learningProgressPct : Math.round((done / total) * 100);
  return (
    <div className="hero" data-screen-label="01 Atlas">
      <div className="preamble">
        <div className="lhs">
          <div className="kicker">{t.tagline}</div>
          <h1>
            {t.heroTitle[0]}<br />
            <em className="acc">{t.heroTitle[1]}</em><br />
            {t.heroTitle[2]}
          </h1>
          <p className="lede">{t.heroLede}</p>
          <div className="actions">
            <button className="btn primary" onClick={onCta}>{t.cta} <span className="arr">→</span></button>
            <button className="btn" onClick={onHowItWorks}>{t.ctaSecondary}</button>
          </div>
        </div>
        <div className="rhs">
          <QuestionOfDay t={t} locale={locale} onJump={onJump} />
          <CryptoCalendar t={t} locale={locale} />
        </div>
      </div>

      <div className="atlas-host">
        <div className="atlas-titlebar">
          <div className="atlas-name">{t.atlasTitle}</div>
          <div className="atlas-titlebar-lede">{t.atlasLede}</div>
        </div>
        <Atlas t={t} locale={locale} selected={selected} onSelect={onSelect}
               completed={completed} onComplete={onComplete} progressPct={pct} />
      </div>

      <div className="stats">
        <Stat k="BLOCKS" v="6" tt="TON · ETH · BTC · RWA · GameFi · Foundations" />
        <Stat k="TOPICS" v="57" tt="Across all ecosystems" />
        <Stat k="INSIGHTS" v="142" tt="Cards across the atlas" />
        <Stat k="TOOLS" v="57" tt="Wallets, explorers, protocols" />
      </div>
    </div>
  );
}

function Stat({ k, v, tt }) {
  return (
    <div className="stat">
      <div className="k">{k}</div>
      <div className="v">{v}</div>
      <div className="t">{tt}</div>
    </div>
  );
}

function Rail({ t, locale, selected, setSelected, saved, onSave, onReadLesson, onClose, open }) {
  const railRef = useRef_app(null);
  const closeRef = useRef_app(null);
  useEffect_app(() => {
    if (!open || !railRef.current) return;
    railRef.current.scrollTop = 0;
    requestAnimationFrame(() => { try { closeRef.current && closeRef.current.focus({ preventScroll: true }); } catch (e) {} });
  }, [open, selected]);
  const { NODES, INSIGHTS, EDGES } = window.LUMINARA_DATA;
  const node = NODES.find(n => n.id === selected);
  const ins = INSIGHTS[selected];
  // QA-005: some atlas nodes (e.g. the TON node, id "ton") have no INSIGHTS entry keyed by their
  // id, which left the rail description blank. Fall back to the node's ecosystem blurb (matched by
  // group) so the card always shows something meaningful.
  const eco = (window.LUMINARA_DATA.ECOSYSTEMS || []).find(e => node && e.id === node.group);
  const ecoBlurb = (eco && eco.blurb) ? (eco.blurb[locale] || eco.blurb.en || "") : "";
  const kickerText = (ins && ins.kicker && (ins.kicker[locale] || ins.kicker.en)) || (eco && eco.title) || (node && node.group) || "";
  const ledeText = (ins && ins.lede && (ins.lede[locale] || ins.lede.en)) || ecoBlurb || "";

  const connected = EDGES
    .filter(([a, b]) => a === selected || b === selected)
    .map(([a, b]) => a === selected ? b : a);

  if (!node) return <aside className="rail" />;

  const groupColor = {
    foundations: "var(--c-foundations)",
    ethereum:    "var(--c-eth)",
    ton:         "var(--c-ton)",
    bitcoin:     "var(--c-btc)",
    rwa:         "var(--c-rwa)",
  }[node.group];

  return (
    <aside className="rail" data-screen-label="00 Rail">
      <div className="rail-header">
        <div className="rail-grab" aria-hidden="true" />
        <button ref={closeRef} className="rail-close" aria-label={({ ru:"Закрыть", uk:"Закрити", kk:"Жабу", uz:"Yopish", es:"Cerrar", fr:"Fermer", hy:"Փակել", en:"Close" })[locale] || "Close"} onClick={onClose}>✕</button>
        <div className="mono" style={{ color: 'var(--text-dim)', marginBottom: 12 }}>{t.selected}</div>
      </div>
      <div className="rail-scroll" ref={railRef}>

      <div className="rail-card" style={{ color: groupColor }}>
        <div className="head">
          <span className="kicker">
            {kickerText}
          </span>
          <span className="dot" />
        </div>
        <h3 style={{ color: 'var(--text)' }}>{node.title[locale] || node.title.en}</h3>
        <p className="lede">{ledeText}</p>
        <div className="meta">
          <span>{ins ? t.readingTime.replace("{n}", ins.readMin) : ""}</span>
          <button onClick={onSave} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: saved ? 'var(--accent)' : 'var(--text-mid)' }}>
            <PinIcon /> {saved ? t.saved : t.saveToJournal}
          </button>
        </div>
        {(() => {
          const ext = (window.LUMINARA_DATA.EXTERNAL || {})[selected];
          const redirect = NODE_CHAPTER_REDIRECT[selected];
          const hasLesson = !!(ext && ext.scenes && ext.scenes.length) || !!redirect;
          if (!hasLesson || !onReadLesson) return null;
          const lbl = redirect
            ? (({ en: "Open course", ru: "Открыть курс", uk: "Відкрити курс", kk: "Курсты ашу", uz: "Kursni ochish", es: "Abrir curso", fr: "Ouvrir le cours", hy: "Բացել դասընթացը" })[locale] || "Open course")
            : (({ en: "Read the lesson", ru: "Читать урок", uk: "Читати урок", kk: "Сабақты оқу", uz: "Darsni oʻqish", es: "Leer la lección", fr: "Lire la leçon", hy: "Կարդալ դասը" })[locale] || "Read the lesson");
          return (
            <button className="btn primary" style={{ marginTop: 12, width: "100%" }}
                    onClick={() => onReadLesson(selected)}>
              {lbl} <span className="arr">→</span>
            </button>
          );
        })()}
      </div>

      <div className="rail-section">
        <div className="h">{t.connections} <span style={{ color: 'var(--text-dim)' }}>{connected.length}</span></div>
        <div className="conn-list">
          {connected.slice(0, 7).map(id => {
            const n = NODES.find(x => x.id === id);
            const c = {
              foundations: "var(--c-foundations)",
              ethereum: "var(--c-eth)",
              ton: "var(--c-ton)",
              bitcoin: "var(--c-btc)",
              rwa: "var(--c-rwa)",
            }[n.group];
            return (
              <div key={id} className="conn" role="button" tabIndex={0} style={{ color: c }}
                   onClick={() => setSelected(id)}
                   onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected(id); } }}
                   aria-label={n.title[locale] || n.title.en}>
                <span className="swatch" aria-hidden="true" />
                <span style={{ color: 'var(--text)' }}>{n.title[locale] || n.title.en}</span>
                <span className="arr" aria-hidden="true">→</span>
              </div>
            );
          })}
        </div>
      </div>

      <div className="rail-section">
        <div className="h">key questions</div>
        <div className="qa-list">
          {[t.whatsHappening, t.whyMatters, t.howConnected, t.whereValue, t.howParticipate, t.risks, t.fundamental, t.noise].map((q, i) => (
            <div key={i} className="qa">
              <span className="n">{String(i + 1).padStart(2, "0")}</span>
              <span>{q}</span>
            </div>
          ))}
        </div>
      </div>
      </div>
    </aside>
  );
}

// ── Icons (inline SVG, no external deps) ──
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" };

function SearchIcon() { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>); }
function BellIcon() { return (<svg width="16" height="16" viewBox="0 0 24 24" {...stroke}><path d="M18 16v-5a6 6 0 1 0-12 0v5l-2 2h16l-2-2z"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>); }

// NEW-NOTIF-BELL: notifications dropdown. The shell + empty-state are wired now;
// the feed source (new available content, news, course updates) is fed once those
// events exist (news module / content-update hooks). Closes NAV-BELL-DEAD.
function NotificationsBell({ t, locale }) {
  const [open, setOpen] = useState_app(false);
  const ref = useRef_app(null);
  useEffect_app(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    const onEsc = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onEsc);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onEsc); };
  }, [open]);

  const ui = {
    title: { en: "Notifications", ru: "Уведомления", uk: "Сповіщення", kk: "Хабарламалар", uz: "Bildirishnomalar", es: "Notificaciones", fr: "Notifications", hy: "Ծանուցումներ" },
    empty: { en: "Nothing new yet. New lessons, news and course updates will appear here.",
             ru: "Пока ничего нового. Здесь появятся новые уроки, новости и обновления курсов.",
             uk: "Поки нічого нового. Тут з'являться нові уроки, новини та оновлення курсів.",
             kk: "Әзірге жаңалық жоқ. Мұнда жаңа сабақтар, жаңалықтар және курс жаңартулары пайда болады.",
             uz: "Hozircha yangilik yo'q. Bu yerda yangi darslar, yangiliklar va kurs yangilanishlari paydo bo'ladi.", es: "Nada nuevo todavía. Aquí aparecerán nuevas lecciones, noticias y novedades del curso.", fr: "Rien de neuf pour l'instant. Les nouvelles leçons, actualités et mises à jour du cours apparaîtront ici.", hy: "Դեռ ոչ մի նոր բան։ Նոր դասերը, նորությունները և դասընթացի թարմացումները կհայտնվեն այստեղ։" },
  };
  const L = (o) => o[locale] || o.en;
  return (
    <div className="notif-wrap" ref={ref}>
      <button className={"icon-btn" + (open ? " active" : "")} aria-label={L(ui.title)}
              aria-expanded={open} onClick={() => setOpen(o => !o)}>
        <BellIcon />
      </button>
      {open && (
        <div className="notif-panel" role="menu">
          <div className="notif-head">{L(ui.title)}</div>
          <div className="notif-empty">
            <div className="notif-empty-ic"><BellIcon /></div>
            <p>{L(ui.empty)}</p>
          </div>
        </div>
      )}
    </div>
  );
}
function GlobeIcon() { return (<svg width="13" height="13" viewBox="0 0 24 24" {...stroke}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 3 2.5 15 0 18M12 3c-2.5 3-2.5 15 0 18"/></svg>); }
function AtlasIcon() { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M3 12h18M12 3v18"/></svg>); }
function BookIcon()  { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><path d="M4 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H4z"/><path d="M20 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>); }
function GraphIcon() { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><circle cx="6" cy="18" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="18" cy="18" r="2"/><path d="M8 17l8-9M16 18h-6"/></svg>); }
function ToolIcon()  { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><path d="M14 3a4 4 0 1 0 5 5l-3 3 3 3-6 6-6-6 3-3-3-3 5-3 2-2z"/></svg>); }
function PinIcon()   { return (<svg width="12" height="12" viewBox="0 0 24 24" {...stroke}><path d="M12 17v5M9 3l6 0v6l3 3-9 3-3-3z"/></svg>); }
function QuizIcon()    { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .9-1 1.7"/><path d="M12 17h.01"/></svg>); }
function MissionIcon() { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><path d="M5 3v18"/><path d="M5 4h11l-2 3 2 3H5"/></svg>); }
function SunIcon()   { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>); }
function MoonIcon()  { return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>); }
function SlidersIcon(){ return (<svg width="14" height="14" viewBox="0 0 24 24" {...stroke}><path d="M4 6h8M16 6h4M4 12h4M12 12h8M4 18h12M20 18h-2"/><circle cx="14" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg>); }

function LuminaraMark() {
  return (
    <svg width="28" height="28" viewBox="0 0 32 32">
      <defs>
        <radialGradient id="lm-g" cx="50%" cy="40%" r="60%">
          <stop offset="0%" stopColor="var(--accent-2)"/>
          <stop offset="60%" stopColor="var(--accent)"/>
          <stop offset="100%" stopColor="var(--accent-soft)"/>
        </radialGradient>
      </defs>
      <circle cx="16" cy="16" r="10" fill="url(#lm-g)"/>
      <circle cx="16" cy="16" r="14" fill="none" stroke="var(--accent)" strokeOpacity="0.6" strokeWidth="0.6"/>
      <circle cx="16" cy="16" r="3" fill="oklch(96% 0.005 285)"/>
    </svg>
  );
}

// ── Insight feedback: «Поделись озарением с Luminara» ──
// Two presentation modes (slider / floating), mic imitation, irregular auto-popup.
function InsightFeedback({ locale }) {
  const [open, setOpen] = useState_app(false);
  const [mode, setMode] = useState_app("slider"); // slider | floating
  const [text, setTextV] = useState_app("");
  const [recording, setRec] = useState_app(false);
  const [secs, setSecs] = useState_app(0);
  const [sent, setSent] = useState_app(false);
  const [micError, setMicError] = useState_app(null);
  const timerRef = React.useRef(null);

  const T = {
    launcher: { en: "Share an insight", ru: "Поделись озарением", uk: "Поділись осяянням", kk: "Инсайтпен бөліс", uz: "Insayt bilan boʻlish", es: "Compartir una idea", fr: "Partager une idée", hy: "Կիսվել գաղափարով" },
    title:    { en: "Share your insight with Luminara", ru: "Поделись своим озарением с Luminara", uk: "Поділись своїм осяянням з Luminara", kk: "Өз инсайтыңмен Luminara-мен бөліс", uz: "Insaytingni Luminara bilan boʻlish", es: "Comparte tu idea con Luminara", fr: "Partage ton idée avec Luminara", hy: "Կիսվիր քո գաղափարով Luminara-ի հետ" },
    sub:      { en: "Not a review — a thought. Tell our AI what clicked, what you doubt, what you'd change.",
                ru: "Не отзыв — мысль. Скажи нашему ИИ, что щёлкнуло, в чём сомневаешься, что бы изменил.",
                uk: "Не відгук — думка. Скажи нашому ШІ, що клацнуло, у чому сумніваєшся, що б змінив.",
                kk: "Пікір емес — ой. Біздің AI-ға не түсінікті болғанын, неден күмәнданатыныңды айт.", uz: "Sharh emas — fikr. Bizning AIga nima tushunarli boʻlganini, nimadan shubhalanayotganingni ayt.", es: "No es una reseña, es un pensamiento. Dile a nuestra IA qué te hizo clic, qué dudas tienes, qué cambiarías.", fr: "Pas un avis — une pensée. Dis à notre IA ce qui a fait tilt, ce dont tu doutes, ce que tu changerais.", hy: "Սա կարծիք չէ՝ միտք է։ Ասա մեր ԱԲ-ին՝ ինչը պարզ դարձավ, ինչում ես կասկածում, ինչ կփոխեիր։" },
    placeholder: { en: "Type your thought… even half of one counts", ru: "Напиши мысль… даже половина уже ценна", uk: "Напиши думку… навіть половина цінна", kk: "Ойыңды жаз… тіпті жартысы да құнды", uz: "Fikringni yoz… hatto yarmi ham qimmatli", es: "Escribe tu pensamiento… hasta la mitad cuenta", fr: "Écris ta pensée… même la moitié compte", hy: "Գրիր քո միտքը… նույնիսկ կեսն արժե" },
    or:       { en: "or", ru: "или", uk: "або", kk: "немесе", uz: "yoki", es: "o", fr: "ou", hy: "կամ" },
    rec:      { en: "Speak — we'll turn it into text", ru: "Говори — превратим в текст", uk: "Говори — перетворимо на текст", kk: "Сөйле — мәтінге айналдырамыз", uz: "Gapir — matnga aylantiramiz", es: "Habla: lo convertiremos en texto", fr: "Parle — on le transforme en texte", hy: "Խոսիր՝ մենք այն կդարձնենք տեքստ" },
    recording:{ en: "Listening… tap to stop", ru: "Слушаю… нажми, чтобы остановить", uk: "Слухаю… натисни, щоб зупинити", kk: "Тыңдап тұрмын… тоқтату үшін бас", uz: "Tinglayapman… toʻxtatish uchun bos", es: "Escuchando… toca para detener", fr: "Écoute… touche pour arrêter", hy: "Լսում եմ… հպիր՝ կանգնեցնելու" },
    voiceNote:{ en: "Voice input uses your browser's recognition, which may send audio to Google's servers for transcription. Only the text is sent to us — review it before sending.",
                ru: "Голосовой ввод использует распознавание браузера, которое может отправлять аудио на серверы Google для расшифровки. Нам уходит только текст — проверь его перед отправкой.",
                uk: "Голосове введення використовує розпізнавання браузера, яке може надсилати аудіо на сервери Google для розшифрування. Нам надходить лише текст — перевір його перед надсиланням.",
                kk: "Дауыспен енгізу браузердің тануын пайдаланады, ол аудионы Google серверлеріне жіберуі мүмкін. Бізге тек мәтін келеді — жібермес бұрын тексер.",
                uz: "Ovozli kiritish brauzer tanishidan foydalanadi, u audioni transkripsiya uchun Google serverlariga yuborishi mumkin. Bizga faqat matn keladi — yuborishdan oldin tekshir.", es: "La entrada de voz usa el reconocimiento de tu navegador, que puede enviar audio a los servidores de Google para transcribirlo. A nosotros solo nos llega el texto; revísalo antes de enviarlo.", fr: "La saisie vocale utilise la reconnaissance de ton navigateur, qui peut envoyer l'audio aux serveurs de Google pour transcription. Seul le texte nous parvient — relis-le avant d'envoyer.", hy: "Ձայնային մուտքն օգտագործում է քո բրաուզերի ճանաչումը, որը կարող է աուդիոն ուղարկել Google-ի սերվերներ՝ վերծանելու համար։ Մեզ հասնում է միայն տեքստը՝ ստուգիր այն մինչ ուղարկելը։" },
    micDenied:{ en: "Microphone access denied. You can still type your thought.", ru: "Доступ к микрофону запрещён. Можно просто написать мысль.", uk: "Доступ до мікрофона заборонено. Можна просто написати думку.", kk: "Микрофонға рұқсат жоқ. Ойыңды жазсаң да болады.", uz: "Mikrofonga ruxsat yoʻq. Fikringni yozsang ham boʻladi.", es: "Acceso al micrófono denegado. Aún puedes escribir tu pensamiento.", fr: "Accès au micro refusé. Tu peux toujours écrire ta pensée.", hy: "Խոսափողի հասանելիությունը մերժվեց։ Կարող ես միևնույն է գրել քո միտքը։" },
    micNo:    { en: "Voice input isn't supported in this browser. You can still type your thought.", ru: "Голосовой ввод не поддерживается в этом браузере. Можно просто написать мысль.", uk: "Голосове введення не підтримується в цьому браузері. Можна просто написати думку.", kk: "Бұл браузерде дауыспен енгізу қолданылмайды. Ойыңды жазсаң да болады.", uz: "Bu brauzerda ovozli kiritish qoʻllab-quvvatlanmaydi. Fikringni yozsang ham boʻladi.", es: "La entrada de voz no es compatible con este navegador. Aún puedes escribir tu pensamiento.", fr: "La saisie vocale n'est pas prise en charge par ce navigateur. Tu peux toujours écrire ta pensée.", hy: "Ձայնային մուտքն այս բրաուզերում չի աջակցվում։ Կարող ես միևնույն է գրել քո միտքը։" },
    micNetwork:{ en: "Voice recognition couldn't connect right now. Try again, or type your thought.", ru: "Распознавание сейчас не смогло подключиться. Попробуй ещё раз или напиши мысль.", uk: "Розпізнавання зараз не змогло підключитися. Спробуй ще раз або напиши думку.", kk: "Тану қазір қосыла алмады. Қайта көр немесе ойыңды жаз.", uz: "Tanish hozir ulana olmadi. Qayta urinib koʻr yoki fikringni yoz.", es: "El reconocimiento de voz no pudo conectarse ahora. Inténtalo de nuevo o escribe tu pensamiento.", fr: "La reconnaissance vocale n'a pas pu se connecter. Réessaie, ou écris ta pensée.", hy: "Ձայնի ճանաչումը հիմա չկարողացավ միանալ։ Փորձիր նորից կամ գրիր քո միտքը։" },
    micNoMic:  { en: "No microphone found. You can still type your thought.", ru: "Микрофон не найден. Можно просто написать мысль.", uk: "Мікрофон не знайдено. Можна просто написати думку.", kk: "Микрофон табылмады. Ойыңды жазсаң да болады.", uz: "Mikrofon topilmadi. Fikringni yozsang ham boʻladi.", es: "No se encontró micrófono. Aún puedes escribir tu pensamiento.", fr: "Aucun micro trouvé. Tu peux toujours écrire ta pensée.", hy: "Խոսափող չգտնվեց։ Կարող ես միևնույն է գրել քո միտքը։" },
    micNoResult:{ en: "Didn't catch any speech — try again, closer to the mic, or just type it.", ru: "Не удалось разобрать речь — попробуй ещё раз, ближе к микрофону, или просто напиши.", uk: "Не вдалося розібрати мову — спробуй ще раз, ближче до мікрофона, або просто напиши.", kk: "Сөзді тани алмадым — қайта көр, микрофонға жақынырақ, немесе жазып жібер.", uz: "Nutqni ajrata olmadim — qayta urin, mikrofonga yaqinroq, yoki shunchaki yoz.", es: "No se captó ninguna voz: inténtalo de nuevo, más cerca del micrófono, o escríbelo.", fr: "Aucune parole captée — réessaie, plus près du micro, ou écris-le.", hy: "Խոսք չհայտնաբերվեց՝ փորձիր նորից, ավելի մոտ խոսափողին, կամ պարզապես գրիր։" },
    send:     { en: "Save insight", ru: "Сохранить инсайт", uk: "Зберегти інсайт", kk: "Инсайтты сақтау", uz: "Insaytni saqlash", es: "Guardar idea", fr: "Sauvegarder l'idée", hy: "Պահել գաղափարը" },
    // Honest beta copy: nothing leaves the device yet (no backend wired).
    thanks:   { en: "Saved on this device. In the beta it isn't sent to a server yet — soon it will reach the team.", ru: "Сохранено на этом устройстве. В бете пока не уходит на сервер — скоро будет доходить до команды.", uk: "Збережено на цьому пристрої. У беті поки не йде на сервер — скоро доходитиме до команди.", kk: "Осы құрылғыда сақталды. Бетада әзірге серверге жіберілмейді — жақында командаға жетеді.", uz: "Shu qurilmada saqlandi. Betada hozircha serverga yuborilmaydi — tez orada jamoaga yetadi.", es: "Guardado en este dispositivo. En la beta aún no se envía a un servidor; pronto llegará al equipo.", fr: "Sauvegardé sur cet appareil. En bêta, ce n'est pas encore envoyé à un serveur — bientôt ça atteindra l'équipe.", hy: "Պահված է այս սարքում։ Բետայում դեռ սերվեր չի ուղարկվում՝ շուտով կհասնի թիմին։" },
    betaNote: { en: "Beta: stored on your device only — not sent yet.", ru: "Бета: хранится только на твоём устройстве — пока не отправляется.", uk: "Бета: зберігається лише на твоєму пристрої — поки не надсилається.", kk: "Бета: тек құрылғыңда сақталады — әзірге жіберілмейді.", uz: "Beta: faqat qurilmangda saqlanadi — hozircha yuborilmaydi.", es: "Beta: almacenado solo en tu dispositivo, aún no enviado.", fr: "Bêta : stocké uniquement sur ton appareil — pas encore envoyé.", hy: "Բետա՝ պահվում է միայն քո սարքում, դեռ չի ուղարկվել։" },
  };
  const L = (o) => o[locale] || o.en;

  // irregular auto-popup (demo: first appearance after a random 25–45s, once)
  useEffect_app(() => {
    if (localStorage.getItem("lum-fb-shown")) return;
    const delay = 25000 + Math.random() * 20000;
    const id = setTimeout(() => {
      setMode(Math.random() > 0.5 ? "slider" : "floating");
      setOpen(true);
      localStorage.setItem("lum-fb-shown", "1");
    }, delay);
    return () => clearTimeout(id);
  }, []);

  // ── Voice → text via Web Speech API (no audio leaves as a blob) ──
  // The recognised transcript is appended to the editable textarea; the user reviews
  // and edits it before sending. Only TEXT is submitted — never an audio recording.
  // NOTE: in most browsers (Chrome) Web Speech sends audio to Google's servers for
  // recognition. This is disclosed to the user in the disclaimer below (T.voiceNote).
  const recogRef = React.useRef(null);
  const SpeechRec = (typeof window !== "undefined") &&
    (window.SpeechRecognition || window.webkitSpeechRecognition);

  // FEEDBACK-VOICE-STT: opt-in diagnostics for the "mic activates but no transcript"
  // report. Enable with ?sttdebug=1 (or window.LUM_STT_DEBUG). Off by default — normal
  // users never see it. The event trace shows how far the pipeline gets (start →
  // audiostart → speechstart → result / nomatch / error / end) to pinpoint the cause.
  const STT_DEBUG = (typeof window !== "undefined") &&
    (window.LUM_STT_DEBUG || /[?&]sttdebug=1/.test(window.location.search));
  const [dbg, setDbg] = useState_app([]);
  const pushDbg = (msg) => {
    try { console.log("[STT]", msg); } catch (e) {}
    if (STT_DEBUG) setDbg((d) => [...d.slice(-7), msg]);
  };

  const startRec = () => {
    setMicError(null);
    if (!SpeechRec) { setMicError(L(T.micNo)); return; }
    try {
      const rec = new SpeechRec();
      rec.lang = ({ ru: "ru-RU", uk: "uk-UA", kk: "kk-KZ", uz: "uz-UZ", en: "en-US" })[locale] || "en-US";
      rec.interimResults = true;
      rec.continuous = false; // short feedback dictation; Chrome is more reliable per-utterance
      // Accumulate across result events. We track the text that existed BEFORE this
      // dictation session in a ref, append finalized chunks to it, and show any
      // interim text live. Using the functional setState form avoids stale-closure
      // and batching issues when the callback fires outside React's event loop.
      const sessionBase = (text ? text + " " : "");
      let committed = "";   // finalized transcript so far this session
      let gotResult = false, aborted = false, errored = false;
      rec.onresult = (e) => {
        let interim = "";
        for (let i = e.resultIndex; i < e.results.length; i++) {
          const tr = e.results[i][0].transcript;
          if (e.results[i].isFinal) committed += tr + " ";
          else interim += tr;
        }
        if ((committed + interim).trim()) gotResult = true;
        const next = (sessionBase + committed + interim).slice(0, 2000);
        setTextV(next);
        pushDbg("result final=" + e.results[e.resultIndex].isFinal);
      };
      rec.onerror = (ev) => {
        const err = ev && ev.error;
        pushDbg("error: " + err);
        if (err === "aborted") { aborted = true; setRec(false); return; }
        if (err === "no-speech") { setRec(false); return; }   // → onend shows the no-result hint
        errored = true;
        if (err === "not-allowed" || err === "service-not-allowed") {
          setMicError(L(T.micDenied));
        } else if (err === "network") {
          setMicError(L(T.micNetwork));
        } else if (err === "audio-capture") {
          setMicError(L(T.micNoMic));
        } else {
          setMicError(L(T.micNetwork)); // generic runtime failure, NOT "unsupported browser"
        }
        setRec(false);
      };
      rec.onend = () => {
        pushDbg("end (gotResult=" + gotResult + ")");
        setRec(false);
        // Silent failure → turn it into an actionable hint instead of nothing.
        if (!gotResult && !aborted && !errored) setMicError(L(T.micNoResult));
      };
      // Lifecycle trace (only logged; visible in-panel with ?sttdebug=1).
      rec.onstart = () => pushDbg("start lang=" + rec.lang);
      rec.onaudiostart = () => pushDbg("audiostart");
      rec.onspeechstart = () => pushDbg("speechstart");
      rec.onspeechend = () => pushDbg("speechend");
      rec.onaudioend = () => pushDbg("audioend");
      rec.onnomatch = () => pushDbg("nomatch");
      recogRef.current = rec;
      if (STT_DEBUG) setDbg([]);
      rec.start();
      setSecs(0); setRec(true);
    } catch (e) {
      setMicError(L(T.micDenied));
      setRec(false);
    }
  };
  const stopRec = () => {
    try { if (recogRef.current) recogRef.current.stop(); } catch (e) {}
    setRec(false);
  };
  const toggleRec = () => { recording ? stopRec() : startRec(); };

  // recording timer; auto-stop at 60s
  useEffect_app(() => {
    if (recording) {
      timerRef.current = setInterval(() => {
        setSecs(s => { if (s >= 60) { stopRec(); return 60; } return s + 1; });
      }, 1000);
    } else {
      clearInterval(timerRef.current);
    }
    return () => clearInterval(timerRef.current);
  }, [recording]);

  // cleanup on unmount
  useEffect_app(() => () => {
    try { if (recogRef.current) recogRef.current.stop(); } catch (e) {}
  }, []);

  const reset = () => { setTextV(""); stopRec(); setSecs(0); setSent(false); setMicError(null); };
  const close = () => { setOpen(false); setTimeout(reset, 300); };
  const fmt = (s) => `0:${String(s).padStart(2, "0")}`;

  const panel = (
    <div className="fb-panel">
      <button className="fb-x" onClick={close} aria-label="Закрыть">✕</button>
      <div className="fb-glow" />
      {!sent ? (
        <React.Fragment>
          <div className="fb-title">{L(T.title)}</div>
          <div className="fb-sub">{L(T.sub)}</div>

          <textarea className="fb-textarea" rows={3}
                    placeholder={L(T.placeholder)}
                    value={text} onChange={e => setTextV(e.target.value)} />

          <div className="fb-or" style={{ display: "none" }}><span>{L(T.or)}</span></div>

          {/* FEEDBACK-VOICE-STT: transcription is unreliable on some Chrome/macOS setups (mic
              activates, no transcript). The recognition pipeline + lifecycle trace are wired; the
              mic button is shown only in diagnostics mode (?sttdebug=1) so it can be captured live
              without exposing an unreliable feature to all users. Re-enable for everyone by
              switching the guard back to `SpeechRec ?` once the root cause is fixed. */}
          {STT_DEBUG && SpeechRec ? (
            <button className={"fb-mic " + (recording ? "rec" : "")} onClick={toggleRec}>
              <span className="fb-mic-ic">
                <svg viewBox="0 0 24 24" width="18" height="18" fill="none">
                  <rect x="9" y="3" width="6" height="11" rx="3" fill="currentColor"/>
                  <path d="M6 11a6 6 0 0 0 12 0M12 17v4M9 21h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
                </svg>
              </span>
              {recording ? (
                <span className="fb-wave">
                  {Array.from({length: 14}).map((_, i) => <i key={i} style={{ animationDelay: (i * 0.07) + "s" }} />)}
                </span>
              ) : <span className="fb-mic-lbl">{L(T.rec)}</span>}
              <span className="fb-mic-time">{recording ? fmt(secs) : ""}</span>
            </button>
          ) : null}
          {recording && <div className="fb-rec-hint">{L(T.recording)}</div>}
          {micError && <div className="fb-rec-hint" style={{ color: "var(--accent)" }}>{micError}</div>}
          {STT_DEBUG && dbg.length > 0 && (
            <div className="fb-stt-debug" style={{ marginTop: 8, fontFamily: "var(--f-mono, monospace)", fontSize: 11, opacity: 0.75, whiteSpace: "pre-wrap", lineHeight: 1.5 }}>
              {dbg.join("\n")}
            </div>
          )}
          {SpeechRec && <div className="fb-voice-note">{L(T.voiceNote)}</div>}

          <button className="fb-send" disabled={!text.trim()} onClick={() => setSent(true)}>
            {L(T.send)} <span className="arr">→</span>
          </button>
          <div className="fb-beta-note">{L(T.betaNote)}</div>
        </React.Fragment>
      ) : (
        <div className="fb-thanks">
          <div className="fb-check">✓</div>
          <div className="fb-thanks-tx">{L(T.thanks)}</div>
          <button className="fb-send" onClick={close} style={{ marginTop: 16 }}>OK</button>
        </div>
      )}
    </div>
  );

  return (
    <React.Fragment>
      <button className="fb-launcher" onClick={() => { setMode("slider"); setOpen(true); }}
              title={L(T.launcher)} aria-label={L(T.launcher)}>
        <svg viewBox="0 0 24 24" width="20" height="20" fill="none">
          <path d="M12 3l2.1 5.3L20 9.2l-4 3.6 1 5.9-5-3-5 3 1-5.9-4-3.6 5.9-.9L12 3z" fill="currentColor"/>
        </svg>
      </button>

      {open && mode === "floating" && (
        <div className="fb-overlay" onClick={(e) => { if (e.target === e.currentTarget) close(); }}>
          <div className="fb-floating">{panel}</div>
        </div>
      )}
      {open && mode === "slider" && (
        <React.Fragment>
          <div className="fb-slider-scrim" onClick={close} />
          <div className={"fb-slider open"}>{panel}</div>
        </React.Fragment>
      )}
    </React.Fragment>
  );
}

// ── Viral onboarding: hook → insight → mental-map share (TICKET-016) ──
function ViralOnboarding({ t, locale, completed, onClose, onEnter }) {
  const L = (o) => o[locale] || o.en;
  const C = {
    skip:    { en: "Skip", ru: "Пропустить", uk: "Пропустити", kk: "Өткізу", uz: "Oʻtkazib yuborish", es: "Omitir", fr: "Passer", hy: "Բաց թողնել" },
    next:    { en: "Next", ru: "Дальше", uk: "Далі", kk: "Әрі қарай", uz: "Keyingi", es: "Siguiente", fr: "Suivant", hy: "Հաջորդ" },
    enter:   { en: "Enter the atlas", ru: "Войти в атлас", uk: "Увійти в атлас", kk: "Атласқа кіру", uz: "Atlasga kirish", es: "Entrar al atlas", fr: "Entrer dans l'atlas", hy: "Մուտք գործել ատլաս" },
    share:   { en: "Share my map", ru: "Поделиться картой", uk: "Поділитися картою", kk: "Картаны бөлісу", uz: "Xaritani ulashish", es: "Compartir mi mapa", fr: "Partager ma carte", hy: "Կիսվել իմ քարտեզով" },
    copied:  { en: "Link copied", ru: "Ссылка скопирована", uk: "Посилання скопійовано", kk: "Сілтеме көшірілді", uz: "Havola nusxalandi", es: "Enlace copiado", fr: "Lien copié", hy: "Հղումը պատճենվեց" },
    step:    { en: "Step", ru: "Шаг", uk: "Крок", kk: "Қадам", uz: "Qadam", es: "Paso", fr: "Étape", hy: "Քայլ" },
  };
  const STEPS = [
    {
      kind: "hook",
      tag: { en: "The new internet", ru: "Новый интернет", uk: "Новий інтернет", kk: "Жаңа интернет", uz: "Yangi internet", es: "El nuevo internet", fr: "Le nouvel internet", hy: "Նոր ինտերնետը" },
      title: {
        en: "Everyone talks about Web3, AI and crypto. Almost no one sees how it all connects.",
        ru: "Все говорят о Web3, AI и крипте. Почти никто не видит, как это связано.",
        uk: "Усі говорять про Web3, AI і крипту. Майже ніхто не бачить, як це пов’язано.",
        kk: "Бәрі Web3, AI және крипто туралы айтады. Бірақ олардың қалай байланысатынын ешкім көрмейді.",
        uz: "Hamma Web3, AI va kripto haqida gapiradi. Lekin bularning qanday bogʻlanishini deyarli hech kim koʻrmaydi.", es: "Todos hablan de Web3, IA y cripto. Casi nadie ve cómo se conecta todo.", fr: "Tout le monde parle de Web3, d'IA et de crypto. Presque personne ne voit comment tout cela se relie.", hy: "Բոլորը խոսում են Web3-ի, ԱԲ-ի և կրիպտոյի մասին։ Գրեթե ոչ ոք չի տեսնում, թե ինչպես է ամեն ինչ կապվում։" },
      body: {
        en: "Not another course. A map you actually walk through.",
        ru: "Не очередной курс. Карта, по которой ты идёшь сам.",
        uk: "Не черговий курс. Карта, якою ти йдеш сам.",
        kk: "Кезекті курс емес. Өзің жүретін карта.",
        uz: "Navbatdagi kurs emas. Oʻzing yuradigan xarita.", es: "No es otro curso. Es un mapa que de verdad recorres.", fr: "Pas un cours de plus. Une carte que tu parcours vraiment.", hy: "Հերթական դասընթացը չէ։ Քարտեզ, որով իրականում քայլում ես։" },
    },
    {
      kind: "insight",
      tag: { en: "First insight", ru: "Первый инсайт", uk: "Перший інсайт", kk: "Алғашқы инсайт", uz: "Birinchi insayt", es: "Primera idea", fr: "Première idée", hy: "Առաջին գաղափարը" },
      title: {
        en: "AI belongs to Web 3.0 — the intelligent web. Web3 is about ownership. Two different things, one picture.",
        ru: "AI — это Web 3.0, умный интернет. Web3 — про собственность. Две разные вещи, одна картина.",
        uk: "AI — це Web 3.0, розумний інтернет. Web3 — про власність. Дві різні речі, одна картина.",
        kk: "AI — Web 3.0, ақылды интернет. Web3 — меншік туралы. Екі түрлі нәрсе, бір сурет.",
        uz: "AI — bu Web 3.0, aqlli internet. Web3 — egalik haqida. Ikki xil narsa, bitta manzara.", es: "La IA pertenece a la Web 3.0, la web inteligente. La Web3 trata sobre la propiedad. Dos cosas distintas, una sola imagen.", fr: "L'IA appartient au Web 3.0 — le web intelligent. Le Web3 concerne la propriété. Deux choses différentes, une seule image.", hy: "ԱԲ-ն պատկանում է Web 3.0-ին՝ խելացի վեբին։ Web3-ը սեփականության մասին է։ Երկու տարբեր բան, մեկ պատկեր։" },
      body: {
        en: "Each node you open lights up — and the connections start to glow.",
        ru: "Каждый узел, что ты открываешь, загорается — и связи начинают светиться.",
        uk: "Кожен вузол, який ти відкриваєш, спалахує — і зв’язки починають світитися.",
        kk: "Әр ашқан түйінің жанады — байланыстар жарқырай бастайды.",
        uz: "Ochgan har bir tuguning yonadi — bogʻlanishlar yorqinlasha boshlaydi.", es: "Cada nodo que abres se ilumina, y las conexiones empiezan a brillar.", fr: "Chaque nœud que tu ouvres s'illumine — et les connexions se mettent à briller.", hy: "Յուրաքանչյուր հանգույց, որ բացում ես, լուսավորվում է՝ և կապերը սկսում են փայլել։" },
    },
    {
      kind: "map",
      tag: { en: "Your mental model", ru: "Твоя ментальная карта", uk: "Твоя ментальна карта", kk: "Сенің ментальді картаң", uz: "Sening mental xaritang", es: "Tu modelo mental", fr: "Ton modèle mental", hy: "Քո մտավոր մոդելը" },
      title: {
        en: "This is the map you'll build — and share.",
        ru: "Вот карта, которую ты соберёшь — и покажешь.",
        uk: "Ось карта, яку ти збереш — і покажеш.",
        kk: "Міне, сен жинайтын — және бөлісетін карта.",
        uz: "Mana sen tuzadigan — va ulashadigan xarita.", es: "Este es el mapa que construirás y compartirás.", fr: "Voici la carte que tu construiras — et partageras.", hy: "Սա այն քարտեզն է, որ կկառուցես և կկիսվես։" },
      body: {
        en: "The more you understand, the brighter your atlas gets.",
        ru: "Чем больше ты понимаешь, тем ярче твой атлас.",
        uk: "Чим більше ти розумієш, тим яскравіший твій атлас.",
        kk: "Неғұрлым көп түсінсең, атласың соғұрлым жарық.",
        uz: "Qancha koʻp tushunsang, atlasing shuncha yorqin.", es: "Cuanto más entiendas, más brillante se vuelve tu atlas.", fr: "Plus tu comprends, plus ton atlas s'illumine.", hy: "Որքան շատ հասկանաս, այնքան քո ատլասը պայծառանում է։" },
    },
  ];

  const [step, setStep] = useState_app(0);
  const [copied, setCopied] = useState_app(false);
  const s = STEPS[step];
  const last = step === STEPS.length - 1;

  const NODES = window.LUMINARA_DATA.NODES;
  const done = completed || new Set();
  const colorOf = (g) =>
    g === "ethereum" ? "var(--c-eth)"
    : g === "ton" ? "var(--c-ton)"
    : g === "bitcoin" ? "var(--c-btc)"
    : g === "rwa" ? "var(--c-rwa)"
    : g === "gamefi" ? "var(--c-game)"
    : "var(--c-foundations)";

  const share = async () => {
    // SHARE-MAP: copy the REAL referral deep-link from the server
    // (t.me/<bot>/<app>?startapp=<code>), never a placeholder. In demo / no session,
    // fall back to the actual app URL (same origin). "Link copied ✓" shows ONLY on a
    // real clipboard write — never a fake success.
    let url = "";
    try {
      if (LUM_API) {
        const r = await LUM_API.referral.code();   // { code, url }
        if (r && r.url) url = r.url;
        else if (r && r.code) {
          const cfg = window.LUMINARA_PUBLIC_CONFIG || {};
          if (cfg.telegramBotUsername && cfg.telegramAppSlug) {
            url = `https://t.me/${cfg.telegramBotUsername}/${cfg.telegramAppSlug}?startapp=${encodeURIComponent(r.code)}`;
          }
        }
      }
    } catch (e) { /* no session / network — fall through to same-origin link */ }
    if (!url) url = window.location.origin + window.location.pathname;   // real, not a placeholder
    try {
      await navigator.clipboard.writeText(url);
      setCopied(true); setTimeout(() => setCopied(false), 1800);
    } catch (e) {
      try { window.prompt("Link", url); } catch (e2) {}   // manual copy; no fake "copied ✓"
    }
  };

  // мини-карта-превью узлов (нормализованная), для шага «map»
  const preview = (
    <div className="vo-map">
      <svg viewBox="0 0 100 70" preserveAspectRatio="xMidYMid meet">
        {window.LUMINARA_DATA.EDGES?.map((e, i) => {
          const a = NODES.find(n => n.id === e[0]);
          const b = NODES.find(n => n.id === e[1]);
          if (!a || !b) return null;
          const ay = 6 + ((a.y - 22) / 58) * 58, by = 6 + ((b.y - 22) / 58) * 58;
          const lit = done.has(a.id) && done.has(b.id);
          return <line key={i} x1={a.x} y1={ay} x2={b.x} y2={by}
                       stroke={lit ? "var(--accent)" : "var(--line)"}
                       strokeWidth={lit ? 0.7 : 0.4} opacity={lit ? 0.9 : 0.4} />;
        })}
        {NODES.map(n => {
          const ny = 6 + ((n.y - 22) / 58) * 58;
          const isDone = done.has(n.id);
          return <circle key={n.id} cx={n.x} cy={ny} r={Math.max(1.2, (n.r || 12) / 9)}
                         fill={isDone ? colorOf(n.group) : "var(--text-dim)"}
                         opacity={isDone ? 1 : 0.5} />;
        })}
      </svg>
    </div>
  );

  return (
    <div className="vo-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className={"vo-card vo-" + s.kind}>
        <button className="vo-skip" onClick={onClose}>{L(C.skip)} ✕</button>

        <div className="vo-progress">
          {STEPS.map((_, i) => (
            <span key={i} className={"vo-pip" + (i === step ? " on" : "") + (i < step ? " past" : "")} />
          ))}
        </div>

        <div className="vo-tag">{L(s.tag)}</div>
        <h2 className="vo-title">{L(s.title)}</h2>

        {s.kind === "map" && preview}

        <p className="vo-body">{L(s.body)}</p>

        <div className="vo-actions">
          {!last && (
            <button className="btn primary" onClick={() => setStep(step + 1)}>
              {L(C.next)} <span className="arr">→</span>
            </button>
          )}
          {last && (
            <React.Fragment>
              <button className="btn" onClick={share}>{copied ? L(C.copied) + " ✓" : L(C.share)}</button>
              <button className="btn primary" onClick={onEnter}>{L(C.enter)} <span className="arr">→</span></button>
            </React.Fragment>
          )}
        </div>
      </div>
    </div>
  );
}

// Site footer / "подвал" (TICKET-047)
function SiteFooter({ t, locale, setView }) {
  const L = (o) => o[locale] || o.en;
  const ui = {
    tagline: { en: "A cognitive map for the new internet.", ru: "Когнитивная карта нового интернета.", uk: "Когнітивна карта нового інтернету.", kk: "Жаңа интернеттің когнитивтік картасы.", uz: "Yangi internetning kognitiv xaritasi.", es: "Un mapa cognitivo para el nuevo internet.", fr: "Une carte cognitive pour le nouvel internet.", hy: "Ճանաչողական քարտեզ նոր ինտերնետի համար։" },
    explore: { en: "Explore", ru: "Разделы", uk: "Розділи", kk: "Бөлімдер", uz: "Boʻlimlar", es: "Explorar", fr: "Explorer", hy: "Ուսումնասիրել" },
    follow:  { en: "Follow us", ru: "Мы в соцсетях", uk: "Ми в соцмережах", kk: "Әлеуметтік желілерде", uz: "Ijtimoiy tarmoqlarda", es: "Síguenos", fr: "Suis-nous", hy: "Հետևիր մեզ" },
    rights:  { en: "All rights reserved.", ru: "Все права защищены.", uk: "Усі права захищені.", kk: "Барлық құқықтар қорғалған.", uz: "Barcha huquqlar himoyalangan.", es: "Todos los derechos reservados.", fr: "Tous droits réservés.", hy: "Բոլոր իրավունքները պաշտպանված են։" },
  };
  const nav = [
    ["foundations", t.nav?.foundations || "Foundations"],
    ["atlas", t.nav?.atlas || "Atlas"],
    ["ecosystems", t.nav?.ecosystems || "Ecosystems"],
    ["tools", t.nav?.tools || "Tools"],
    ["journal", t.nav?.journal || "Journal"],
  ];
  const socials = [
    ["Telegram", "https://t.me/luminara_tech"],
    ["LinkedIn", "https://www.linkedin.com/company/luminara-education"],
    ["Reddit", "https://www.reddit.com/r/Luminaratech/"],
    ["YouTube", "https://www.youtube.com/@luminara_education"],
  ];
  const open = (url) => {
    try { if (window.Telegram?.WebApp?.openLink) { window.Telegram.WebApp.openLink(url); return; } } catch (e) {}
    window.open(url, "_blank", "noopener");
  };
  return (
    <footer className="site-footer">
      <div className="sf-inner">
        <div className="sf-brand">
          <div className="sf-logo">Luminara</div>
          <div className="sf-tag">{L(ui.tagline)}</div>
        </div>
        <div className="sf-col">
          <div className="sf-h">{L(ui.explore)}</div>
          {nav.map(([k, label]) => (
            <button key={k} className="sf-link" onClick={() => setView(k)}>{label}</button>
          ))}
        </div>
        <div className="sf-col">
          <div className="sf-h">{L(ui.follow)}</div>
          {socials.map(([label, url]) => (
            <button key={label} className="sf-link" onClick={() => open(url)}>{label} ↗</button>
          ))}
        </div>
      </div>
      <div className="sf-bottom">
        <span>© 2026 Luminara</span>
        <span>{L(ui.rights)}</span>
      </div>
    </footer>
  );
}

// Wait for the Directus content loader (if present) before the first render so the
// initial paint already reflects CMS content. Falls back immediately if the loader
// is absent, errors, or times out — never blocks the app from rendering.
(function () {
  function boot() {
    ReactDOM.createRoot(document.getElementById("root")).render(<LuminaraApp />);
  }
  var ready = (typeof window !== "undefined") && window.__luminaraContentReady;
  if (ready && typeof ready.then === "function") {
    ready.then(boot, boot);
  } else {
    boot();
  }
})();
