/* ============================================================ GEMS Group — Shared Components ============================================================ */ const { useEffect, useState, useRef } = React; /* ---------------------------------------------------------- Reveal hook — IntersectionObserver ---------------------------------------------------------- */ function useReveal() { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; // Skip animation if body has no-anim class if (document.body.classList.contains("no-anim")) { el.classList.add("in"); return; } // If already in viewport at mount, reveal immediately on next frame const rect = el.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; if (rect.top < vh * 0.95 && rect.bottom > 0) { requestAnimationFrame(() => el.classList.add("in")); return; } const obs = new IntersectionObserver( (entries) => { entries.forEach((e) => { if (e.isIntersecting) { el.classList.add("in"); obs.unobserve(el); } }); }, { threshold: 0.05, rootMargin: "0px 0px -8% 0px" } ); obs.observe(el); // Safety: ensure it eventually reveals const t = setTimeout(() => el.classList.add("in"), 1500); return () => { obs.disconnect(); clearTimeout(t); }; }, []); return ref; } function Reveal({ as: As = "div", className = "", delay = 0, style, children, ...rest }) { const ref = useReveal(); return ( {children} ); } function RevealStagger({ as: As = "div", className = "", children, ...rest }) { const ref = useReveal(); return ( {children} ); } /* ---------------------------------------------------------- Image placeholder — supports real images (src) OR pattern fallback ---------------------------------------------------------- */ function ImagePlaceholder({ label, tag, tone = "paper", // paper | dark | blue | red aspect, height, className = "", style = {}, src, // real image url overlay, // 0..1 dark scrim opacity (when src set) position = "center", showMeta = true, // toggle the tag/label corner badges children, }) { const toneClass = tone === "dark" ? "placeholder--dark" : tone === "blue" ? "placeholder--blue" : tone === "red" ? "placeholder--red" : ""; const s = { aspectRatio: aspect, height: height, ...style, }; // When src is set, render as a real image with optional dark overlay if (src) { return (
{label {overlay > 0 && (
)} {showMeta && tag && {tag}} {showMeta && label && {label}} {children}
); } return (
{tag && {tag}} {label && {label}} {children}
); } /* ---------------------------------------------------------- PHOTOS — curated brand patterns + unsplash demo refs ---------------------------------------------------------- */ // Local fallback paths — bundled with the site so images never blank when // Directus is unreachable (D5). These are also the seed `source` for Phase 6. const BG_LOCAL = { blueFan: "assets/bg-1.jpg", // deep blue · radial fan stripes (G symbol DNA) blueGradient: "assets/bg-2.jpg", // soft blue/black gradient phoneG: "assets/bm-0.jpg", // red · hand holding G logo phone redRings: "assets/bm-1.jpg", // red concentric circles blueFolds: "assets/bm-2.jpg", // blue diagonal folds redCubes: "assets/bm-3.jpg", // red 3D cubes redRhombic: "assets/bm-4.jpg", // red 3D diamond blocks architecture: "assets/bm-5.jpg", // warm red curved roof on blue sky redSpiral: "assets/bm-6.jpg", // red spiral staircase (compounding!) redCurves: "assets/bm-7.jpg", // red curved discs/stripes redSwirl: "assets/bm-8.jpg", // red swirling rings with orbital elements }; // Demo photography (Unsplash, self-hosted into Directus in Phase 6). Local // fallback keeps the original CDN URL so the site still renders pre-CMS / offline. const UNSPLASH_LOCAL = { boardroom: "https://images.unsplash.com/photo-1573164713714-d95e436ab8d6?auto=format&fit=crop&w=1600&q=80", meeting: "https://images.unsplash.com/photo-1521737711867-e3b97375f902?auto=format&fit=crop&w=1600&q=80", founder: "https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=1200&q=80", classroom: "https://images.unsplash.com/photo-1577896851231-70ef18881754?auto=format&fit=crop&w=1600&q=80", workspace: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?auto=format&fit=crop&w=1600&q=80", handshake: "https://images.unsplash.com/photo-1521791136064-7986c2920216?auto=format&fit=crop&w=1600&q=80", speaker: "https://images.unsplash.com/photo-1475721027785-f74eccf877e2?auto=format&fit=crop&w=1600&q=80", architecture2: "https://images.unsplash.com/photo-1486325212027-8081e485255e?auto=format&fit=crop&w=1600&q=80", }; // Phase 6 media wiring: BG / UNSPLASH resolve to a Directus file URL (webp, // transformed) when CMS content is live, else the local fallback above. Built // as getters so EVERY existing `src={BG.x}` / `src={UNSPLASH.x}` usage routes // through the media library with no change at the call site. Getters run at // render time, so components re-render on CMS load and swap to the managed // image automatically. Editors can replace any of these from Directus admin. function mediaMap(localMap, keyPrefix, width) { const out = {}; Object.keys(localMap).forEach((name) => { Object.defineProperty(out, name, { enumerable: true, get() { const fallback = localMap[name]; return window.cms ? window.cms.asset(keyPrefix + name, { w: width, fallback }) : fallback; }, }); }); return out; } const BG = mediaMap(BG_LOCAL, "bg_", 1920); const UNSPLASH = mediaMap(UNSPLASH_LOCAL, "photo_", 1600); // Resolve a single asset_library key to its managed URL (else local fallback). // Call at render time so it picks up CMS content once loaded. Function // declaration → shared global, usable from every page script. function mediaSrc(key, fallback, width) { return window.cms ? window.cms.asset(key, { w: width || 1600, fallback }) : fallback; } // Reverse index: a local image path → its asset_library key. Lets module-level // data arrays that captured a BG/UNSPLASH value at load time (frozen to the // local fallback) re-resolve to the live CMS image at RENDER time — just wrap // the render site with mediaFrom(item.img). If the value is already a CMS URL // (or unknown), it is returned unchanged. const LOCAL_TO_KEY = {}; Object.keys(BG_LOCAL).forEach((n) => { LOCAL_TO_KEY[BG_LOCAL[n]] = "bg_" + n; }); Object.keys(UNSPLASH_LOCAL).forEach((n) => { LOCAL_TO_KEY[UNSPLASH_LOCAL[n]] = "photo_" + n; }); function mediaFrom(localUrl, width) { const key = LOCAL_TO_KEY[localUrl]; return key ? mediaSrc(key, localUrl, width) : localUrl; } /* ---------------------------------------------------------- Brand stripe accent (small visual motif from the symbol) ---------------------------------------------------------- */ function BrandStripes({ count = 9, height = 24, color }) { const heights = []; for (let i = 0; i < count; i++) { // arc-like distribution: shorter at edges, taller in the middle const t = i / (count - 1); const h = Math.sin(t * Math.PI) * height * 0.85 + height * 0.15; heights.push(h); } return ( {heights.map((h, i) => ( ))} ); } /* ---------------------------------------------------------- Brand pattern primitives — derived from the GEMS Symbol (vertical stripes arc-distributed like the G mark) and the color palette (concentric rings) ---------------------------------------------------------- */ // Vertical stripe band — arc-shaped distribution of bar heights. // Use as a divider or accent. `shape` is the bar-height curve: // "arc" — bell curve (highest in the middle) // "rising" — short to tall (left to right) // "falling" — tall to short // "wave" — sine wave function StripeBand({ count = 48, width = "100%", height = 64, color = "currentColor", shape = "arc", gap = 3, thickness = 2, align = "bottom", // bottom | center | top opacity = 1, }) { const heights = []; for (let i = 0; i < count; i++) { const t = i / Math.max(1, count - 1); let h; switch (shape) { case "rising": h = t; break; case "falling": h = 1 - t; break; case "wave": h = (Math.sin(t * Math.PI * 2) + 1) / 2; break; case "arc": default: h = Math.sin(t * Math.PI); } heights.push(0.08 + 0.92 * h); // min 8% so nothing disappears } return (
{heights.map((h, i) => ( ))}
); } // Concentric arcs (half-circles) — like the GEMS Color Palette artwork function ConcentricArcs({ size = 320, color = "currentColor", count = 6, strokeWidth = 14, startAngle = -90, // 0 = right; -90 = up endAngle = 90, className = "", style = {}, }) { const arcs = []; for (let i = 0; i < count; i++) { const r = (i + 1) * (size / 2 / count) - strokeWidth / 2; const a0 = (startAngle * Math.PI) / 180; const a1 = (endAngle * Math.PI) / 180; const cx = size / 2, cy = size / 2; const x0 = cx + r * Math.cos(a0); const y0 = cy + r * Math.sin(a0); const x1 = cx + r * Math.cos(a1); const y1 = cy + r * Math.sin(a1); const large = Math.abs(endAngle - startAngle) > 180 ? 1 : 0; arcs.push( ); } return ( {arcs} ); } // Full concentric rings (closed circles) — for editorial section markers function ConcentricRings({ size = 200, color = "currentColor", count = 5, strokeWidth = 1, className = "", style = {}, }) { const rings = []; for (let i = 0; i < count; i++) { const r = ((i + 1) / count) * (size / 2 - strokeWidth); rings.push( ); } return ( {rings} ); } /* ---------------------------------------------------------- GEMS Symbol — minified inline SVG (so we can re-color/animate) ---------------------------------------------------------- */ function GemsSymbol({ size = 64, color = "currentColor", className = "" }) { return ( GEMS ); } /* Horizontal logo (Tagline Layout — wide format for nav/footer) */ function GemsLogo({ variant = "black", height = 28, className = "" }) { const file = variant === "blue" ? "assets/logo-h-blue.svg" : variant === "red" ? "assets/logo-h-red.svg" : variant === "white" ? "assets/logo-h-white.svg" : "assets/logo-h-black.svg"; return ( GEMS ); } /* Stacked / primary logo (square format — for hero callouts, big moments) */ function GemsLogoStacked({ variant = "black", height = 80, className = "" }) { const file = variant === "blue" ? "assets/logo-blue.svg" : variant === "red" ? "assets/logo-red.svg" : variant === "white" ? "assets/logo-white.svg" : "assets/logo-black.svg"; return ( GEMS ); } function GemsSymbolMark({ variant = "black", size = 40, className = "" }) { const file = variant === "blue" ? "assets/symbol-blue.svg" : variant === "red" ? "assets/symbol-red.svg" : variant === "white" ? "assets/symbol-white.svg" : "assets/symbol-black.svg"; return ( ); } /* ---------------------------------------------------------- Top navigation (shared, slight variant per option via prop) ---------------------------------------------------------- */ // Map a nav URL to the page key used for active-state + dropdown wiring. const NAV_KEY_BY_URL = { "index.html": "home", "about.html": "about", "solutions.html": "business", "portfolio.html": "portfolio", "insights.html": "insights", "contact.html": "contact", }; function navKeyForUrl(url) { if (!url) return ""; const base = String(url).split("#")[0].split("?")[0]; return NAV_KEY_BY_URL[base] || base.replace(/\.html$/, ""); } // Resolve a nav row's href given its link_type. external passes through as-is; // page/anchor resolve normally (home → "#" when already on home). function navHref(row, onHome) { if (!row) return "#"; const url = row.url || "#"; if (row.link_type === "external") return url; if (navKeyForUrl(url) === "home" && onHome) return "#"; return url; } // target/rel for new-tab links (security: noopener noreferrer). function navTarget(row) { return row && row.open_new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {}; } // Build a 1-level tree (top-level + direct children) from flat nav_items rows. function buildNavTree(rows) { const byId = {}; rows.forEach((r) => { byId[r.id] = { ...r, children: [] }; }); const top = []; rows.forEach((r) => { const node = byId[r.id]; if (r.parent != null && byId[r.parent]) byId[r.parent].children.push(node); else top.push(node); }); const bySort = (a, b) => (a.sort || 0) - (b.sort || 0); top.sort(bySort); top.forEach((n) => n.children.sort(bySort)); return top; } function Nav({ variant = "light", scrolled = false, sticky = true, logoColor, current = "home" }) { const dark = variant === "dark"; const [menuOpen, setMenuOpen] = useState(false); const [lang, setLang] = useLang(); useCms(); // re-render on CMS ready / update / language change // Over-hero state: nav starts on dark hero, text is white. Once scrolled, switches to paper bg + dark text. const overHero = !scrolled; // Logo color: explicit override > scrolled-aware (white over hero, black on scrolled paper) const navLogoColor = logoColor || (overHero ? "white" : "black"); // Direct file path for the requested logo names (assets/GEMS-logo-{w,b}.svg) const logoFile = overHero ? "assets/GEMS-logo-w.svg" : "assets/GEMS-logo-b.svg"; const textColor = overHero ? "white" : "var(--ink)"; // Page-aware nav: hrefs adapt based on which page is active const onHome = current === "home"; // Nav items from CMS (nav_items collection), falling back to hardcoded copy. const cmsNav = window.cms ? window.cms.get("nav_items") : null; // Build a tree from flat rows → top-level items, each carrying its children. // Tuple shape: [label, href, key, node]. node === null for hardcoded fallback. const items = (Array.isArray(cmsNav) && cmsNav.length) ? buildNavTree(cmsNav).map((node) => { const key = navKeyForUrl(node.url); return [L(node.label && node.label.vi, node.label && node.label.en), navHref(node, onHome), key, node]; }) : [ [L("Trang chủ", "Home"), onHome ? "#" : "index.html", "home", null], [L("Về chúng tôi", "About"), "about.html", "about", null], [L("Giải pháp", "Solutions"), "solutions.html", "business", null], [L("Danh mục", "Portfolio"), "portfolio.html", "portfolio", null], [L("Góc nhìn", "Insights"), "insights.html", "insights", null], [L("Liên hệ", "Contact"), "contact.html", "contact", null], ]; // Solutions dropdown: prefer CMS pillar_data, fall back to hardcoded PILLARS. const cmsPillarRows = window.cms ? window.cms.get("pillar_data") : null; const navPillars = (Array.isArray(cmsPillarRows) && cmsPillarRows.length) ? cmsPillarRows.map((r) => ({ id: r.pillar_slug, num: r.roman_numeral, code: r.code, sol: r.solution_title && r.solution_title.vi, solEn: r.solution_title && r.solution_title.en, })) : PILLARS; // Lock body scroll when mobile menu open useEffect(() => { document.body.style.overflow = menuOpen ? "hidden" : ""; return () => { document.body.style.overflow = ""; }; }, [menuOpen]); return (
GEMS
{["vi", "en"].map((lng, i) => ( {i > 0 && /} ))}
{L("Liên hệ", "Contact")}
); } /* ---------------------------------------------------------- Footer (shared) ---------------------------------------------------------- */ const FOOTER_ROMAN = ["I", "II", "III", "IV", "V"]; function Footer() { useCms(); // re-render on CMS ready / update / language change const has = !!window.cms; const fcols = has ? window.cms.get("footer_columns") : null; const flinks = has ? window.cms.get("footer_links") : null; const globals = has ? window.cms.get("site_globals") : null; const about = has ? window.cms.get("page_sections", "footer_about") : null; const colBySort = {}; if (Array.isArray(fcols)) fcols.forEach((c) => { colBySort[c.sort] = c; }); const exploreCol = colBySort[1]; const solutionsCol = colBySort[2]; const hqCol = colBySort[3]; const linksFor = (col) => (col && Array.isArray(flinks)) ? flinks.filter((l) => l.footer_column === col.id) : []; const exploreLinks = linksFor(exploreCol); const solutionLinks = linksFor(solutionsCol); // About block (page_sections.footer_about), with hardcoded fallback. const aboutEyebrow = about && about.eyebrow ? L(about.eyebrow.vi, about.eyebrow.en) : L("— Về GEMS", "— About GEMS"); const aboutFullname = about && about.heading ? L(about.heading.vi, about.heading.en) : "GEMS Education Management & Investment Jsc."; const aboutBlurb = about && about.body ? L(about.body.vi, about.body.en) : L( "Kiến trúc hệ sinh thái giáo dục tái tạo cho định chế và cá nhân — tích hợp tri thức thế giới, công nghệ và sức mạnh tài chính.", "Architecting a regenerative education ecosystem for institutions and individuals — integrating world knowledge, technology and financial strength." ); // Globals (tagline / address / email / copyright / social), with fallbacks. const tagline = globals && globals.tagline ? L(globals.tagline.vi, globals.tagline.en) : "Empowering Minds — Transforming Education"; const email = (globals && globals.footer_email) || "contact@gemsgroup.com.vn"; const address = globals && globals.footer_address ? L(globals.footer_address.vi, globals.footer_address.en) : L("Tầng 3, Toà nhà CINC, Số 8 Trần Hưng Đạo, Hà Nội", "3rd Floor, CINC Building, 8 Tran Hung Dao Street, Hanoi"); const copyright = globals && globals.copyright ? L(globals.copyright.vi, globals.copyright.en) : "© 2026 GEMS Education Management & Investment Jsc. · " + L("Mọi quyền được bảo lưu", "All rights reserved"); const social = (globals && Array.isArray(globals.social_links) && globals.social_links.length) ? globals.social_links : [{ label: "LinkedIn", url: "#" }, { label: "Facebook", url: "#" }]; const legal = (globals && Array.isArray(globals.legal_links) && globals.legal_links.length) ? globals.legal_links.map((x) => ({ label: L(x.label && x.label.vi, x.label && x.label.en), url: x.url || "#" })) : [ { label: L("Chính sách bảo mật", "Privacy Policy"), url: "#" }, { label: L("Điều khoản sử dụng", "Terms of Use"), url: "#" }, ]; const exploreTitle = exploreCol && exploreCol.column_title ? L(exploreCol.column_title.vi, exploreCol.column_title.en) : L("Khám phá", "Explore"); const solutionsTitle = solutionsCol && solutionsCol.column_title ? L(solutionsCol.column_title.vi, solutionsCol.column_title.en) : L("Giải pháp", "Solutions"); const hqTitle = hqCol && hqCol.column_title ? L(hqCol.column_title.vi, hqCol.column_title.en) : L("Trụ sở Hà Nội", "Hanoi Headquarters"); return (
{/* Top band — editorial masthead with red accent */}
GEMS
{tagline}
{aboutEyebrow}
{aboutFullname}

{aboutBlurb}

{exploreTitle}

    {(exploreLinks.length ? exploreLinks : [ { url: "about.html", label: { vi: "Về chúng tôi", en: "About us" } }, { url: "solutions.html", label: { vi: "Giải pháp", en: "Solutions" } }, { url: "portfolio.html", label: { vi: "Danh mục", en: "Portfolio" } }, { url: "insights.html", label: { vi: "Góc nhìn chuyên gia", en: "Insights" } }, { url: "contact.html", label: { vi: "Liên hệ", en: "Contact" } }, ]).map((l, i) => (
  • {L(l.label && l.label.vi, l.label && l.label.en)}
  • ))}

{solutionsTitle}

    {(solutionLinks.length ? solutionLinks : [ { url: "solutions.html#pillar-1", label: { vi: "Đồng kiến tạo tài sản giáo dục", en: "Co-building education assets" } }, { url: "solutions.html#pillar-2", label: { vi: "Chuyển đổi trường học hiện hữu", en: "Transforming existing schools" } }, { url: "solutions.html#pillar-3", label: { vi: "Đưa tri thức thành định chế", en: "Turning knowledge into institutions" } }, ]).map((l, i) => (
  • PILLAR {FOOTER_ROMAN[i] || i + 1}{L(l.label && l.label.vi, l.label && l.label.en)}
  • ))}

{hqTitle}

{address}
{email}

{social.map((s, i) => ( {s.label} ))}
{copyright}
{legal.map((x, i) => ( {i > 0 && ·} {x.label} ))} · gemsgroup.com.vn
); } /* ---------------------------------------------------------- Shared content data (so both options stay consistent) ---------------------------------------------------------- */ const VALUES = [ { num: "01", title: "Kỷ luật", en: "Discipline", body: "Đầu tư có nguyên tắc. Không rượt theo chu kỳ ngắn hạn." }, { num: "02", title: "Hiệu suất", en: "Performance", body: "Đo lường bằng dữ liệu. Báo cáo quý cho từng đối tác." }, { num: "03", title: "Hệ thống", en: "System", body: "Một hệ vận hành thống nhất — đầu tư, tư vấn, đồng quản trị." }, { num: "04", title: "Tích luỹ", en: "Accumulation",body: "Giá trị compounding qua thập niên, không qua quý." }, ]; const SOLUTIONS = [ { code: "CAPITAL", title: "Đầu tư chiến lược", en: "Strategic Investment", body: "Đánh giá, lựa chọn và rót vốn vào các tổ chức giáo dục tư nhân có nền tảng vững vàng và tiềm năng tăng trưởng khu vực.", }, { code: "OPERATIONS", title: "Tư vấn vận hành", en: "Operations Advisory", body: "Tối ưu mô hình kinh doanh, vận hành học thuật và tài chính cho các đơn vị trong danh mục đầu tư.", }, { code: "PORTFOLIO", title: "Quản lý danh mục", en: "Portfolio Management", body: "Theo dõi hiệu suất, tái cấu trúc và mở rộng quy mô các tài sản giáo dục trong danh mục dài hạn.", }, { code: "BRAND", title: "Phát triển thương hiệu", en: "Brand Development", body: "Định vị thương hiệu, xây dựng câu chuyện và năng lực truyền thông cho các đơn vị giáo dục đối tác.", }, ]; const INSIGHTS = [ { cat: "Đầu tư", dom: "CAPITAL", date: "12.05.2026", read: "8 phút", readEn: "8 min", title: "Đầu tư đơn lẻ không đủ — bài học vận hành từ Châu Á", titleEn: "Capital alone is not enough — operating lessons from Asia", excerpt: "Khi vòng đời sản phẩm giáo dục dài, đầu tư đơn thuần là không đủ. Bài viết phân tích vai trò của hệ vận hành tích hợp.", excerptEn: "When the education product lifecycle is long, capital alone falls short. On the role of an integrated operating architecture.", img: "investor meeting / architecture", }, { cat: "Vận hành", dom: "OPERATIONS", date: "05.05.2026", read: "6 phút", readEn: "6 min", title: "Bốn chỉ số quyết định một học viện tư nhân có thể nhân bản", titleEn: "Four metrics that decide whether a private academy can scale", excerpt: "Tỷ lệ giữ chân học viên, hiệu suất giảng viên, biên đóng góp đơn vị và chu kỳ hoàn vốn — bốn KPI cốt lõi.", excerptEn: "Retention, faculty productivity, unit contribution margin and payback period — the four core KPIs.", img: "classroom interior", }, { cat: "Chiến lược", dom: "STRATEGY", date: "28.04.2026", read: "11 phút", readEn: "11 min", title: "Vì sao GEMS chọn đứng cùng người sáng lập thay vì thay thế họ", titleEn: "Why GEMS stands beside founders rather than replacing them", excerpt: "Triết lý đầu tư của chúng tôi: vốn là phương tiện, người sáng lập mới là động lực dài hạn của một thương hiệu giáo dục.", excerptEn: "Our investment philosophy: capital is the means, but the founder is the long-term engine of an education brand.", img: "founders portrait", }, ]; /* ---------------------------------------------------------- PILLARS — Ba trụ cột kinh doanh (từ GEMS Introduction 2026) ---------------------------------------------------------- */ const PILLARS = [ { id: "pillar-1", num: "I", code: "FOUNDATION", en: "GEMS Foundation Partners", vi: "Đối tác Kiến tạo Nền móng", sol: "Đồng kiến tạo tài sản giáo dục", solEn: "Co-building education assets", audienceVi: "Dành cho nhà đầu tư & văn phòng gia đình sẵn sàng đồng kiến tạo những tài sản giáo dục trường tồn.", audienceEn: "For investors & family offices ready to co-build long-duration education assets.", leadVi: "Những công trình vĩ đại nhất sống lâu hơn người dựng nên chúng. Chúng có thể là của bạn.", leadEn: "The greatest structures outlive their builders. They can be yours.", }, { id: "pillar-2", num: "II", code: "TRANSFORMATION", en: "GEMS Transformation Partners", vi: "Đối tác Chuyển đổi", sol: "Chuyển đổi trường học hiện hữu", solEn: "Transforming existing schools", audienceVi: "Dành cho chủ trường & nhà đầu tư sẵn sàng chuyển đổi những tài sản giáo dục hiện hữu.", audienceEn: "For school owners & investors ready to transform existing educational assets.", leadVi: "Bạn đã xây nên một điều có thật. Hãy để nó trở thành tất cả những gì nó vốn có thể trở thành.", leadEn: "You built something real. Now let it become everything it was always capable of being.", }, { id: "pillar-3", num: "III", code: "ACADEMY", en: "GEMS Academy Partners", vi: "Đối tác Học thuật", sol: "Đưa tri thức thành định chế", solEn: "Turning expertise into institutions", audienceVi: "Dành cho nhà thực hành & học giả sẵn sàng biến tri thức thành những định chế trường tồn.", audienceEn: "For practitioners & scholars ready to turn expertise into lasting institutions.", leadVi: "Câu hỏi không còn là bạn biết gì — mà là ai sẽ mang nó đi tiếp khi bạn lùi lại phía sau.", leadEn: "The question is no longer what you know. It is who will carry it forward when you step back.", }, ]; /* ---------------------------------------------------------- Verify — đánh dấu nội dung placeholder cần client xác nhận (ẩn/hiện qua Tweaks → body.hide-verify) ---------------------------------------------------------- */ function Verify({ children, label = "Cần xác nhận", className = "", style }) { return ( {children} {label} ); } /* expose globals so other babel files can use them */ /* ---------------------------------------------------------- Video background (HLS / .m3u8 via hls.js) ---------------------------------------------------------- */ function VideoBg({ src, poster, className = "", style = {} }) { const ref = React.useRef(null); React.useEffect(() => { const v = ref.current; if (!v || !src) return; let hls; const isHls = /\.m3u8(\?|$)/.test(src); if (isHls && v.canPlayType("application/vnd.apple.mpegurl")) { // Safari native v.src = src; } else if (isHls && window.Hls && window.Hls.isSupported()) { hls = new window.Hls({ maxBufferLength: 12 }); hls.loadSource(src); hls.attachMedia(v); } else { v.src = src; } return () => { if (hls) hls.destroy(); }; }, [src]); return (