/* ============================================================ GEMS Article — Chi tiết bài viết (trang đọc) Bài: "Trường học là tài sản không khấu hao" (placeholder) Luận điểm bám theo GEMS Introduction 2026 — Cần xác nhận. ============================================================ */ const ART_TOC = [ { id: "s1", label: "Tài sản hao mòn — trường học thì không", labelEn: "Assets depreciate. Schools don't" }, { id: "s2", label: "Lợi nhuận được bảo vệ bởi thời gian", labelEn: "A return protected by time" }, { id: "s3", label: "Lợi nhuận sâu hơn đến trong im lặng", labelEn: "The deeper return arrives in silence" }, { id: "s4", label: "Điều bạn dựng nên sẽ ở lại", labelEn: "What you build remains" }, ]; // 3 bài liên quan — lấy từ chỉ mục Góc nhìn const ART_RELATED = [ { cat: "TRANSFORMATION", date: "26.05.2026", read: "7 phút", readEn: "7 min", title: "Chẩn đoán trước, vốn sau: trình tự của một cuộc chuyển đổi", titleEn: "Diagnosis before capital: the sequence of a transformation", img: BG.redSpiral }, { cat: "ACADEMY", date: "19.05.2026", read: "6 phút", readEn: "6 min", title: "Tri thức chỉ sống sót khi trở thành cấu trúc", titleEn: "Knowledge survives only when it becomes structure", img: UNSPLASH.speaker }, { cat: "CAPITAL", 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", img: UNSPLASH.founder }, ]; /* ---------------------------------------------------------- Scroll-spy cho mục lục ---------------------------------------------------------- */ function useScrollSpy(ids) { const [active, setActive] = React.useState(ids[0]); React.useEffect(() => { const obs = new IntersectionObserver( (entries) => { entries.forEach((e) => { if (e.isIntersecting) setActive(e.target.id); }); }, { rootMargin: "-20% 0px -70% 0px", threshold: 0 } ); ids.forEach((id) => { const el = document.getElementById(id); if (el) obs.observe(el); }); return () => obs.disconnect(); }, [ids.join(",")]); return active; } /* ---------------------------------------------------------- HERO / HEADER ---------------------------------------------------------- */ // Format an ISO date (YYYY-MM-DD) to the DD.MM.YYYY display used in the byline. function fmtArtDate(iso) { if (typeof iso !== "string") return null; const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[3]}.${m[2]}.${m[1]}` : null; } /* ---------------------------------------------------------- Phase 4 — CMS wiring helpers (swap DATA SOURCE only, never layout). All four article rows already exist in page_sections (article_byline, article_meta, article_related, article_newsletter) + insight_articles. Prose body stays hardcoded (Tier C — rich inline JSX no flat field holds). ---------------------------------------------------------- */ // Pick {vi,en} from a CMS field with EN→VI fallback; else use hardcoded L(vi,en). const artFld = (obj, vi, en) => obj && typeof obj === "object" && ("vi" in obj || "en" in obj) && window.cms ? window.cms.t(obj) : L(vi, en); // Phase 4 — the article label bags are now SINGLETON collections (one translatable // field per label) instead of `.data` JSON blobs. Field lists drive the per-bag // Visual-Editor drawer (one click opens a form with VI/EN tabs for every field). const BYLINE_FIELDS = ["author_name", "author_role", "author_label", "author_bio", "read_time", "category_label"]; const META_FIELDS = ["crumb_root", "toc_heading", "share_heading", "share_copy", "share_copied", "end_of_article", "image_label"]; const NEWS_FIELDS = ["label", "email_placeholder", "submit_label", "note"]; // article_id → image (CMS carries no article images; keep the code map, same // approach as insights.jsx, which article.html does not load). const ART_IMG = { p1: UNSPLASH.classroom, p2: BG.redSpiral, p3: UNSPLASH.speaker, p4: UNSPLASH.boardroom, p5: BG.blueFolds, p6: UNSPLASH.founder, p7: UNSPLASH.meeting, p8: BG.redCurves, p9: BG.redSwirl, }; // category enum → uppercase label shown on the related cards. const ART_CAT_EN = { capital: "CAPITAL", transform: "TRANSFORMATION", academy: "ACADEMY" }; // Derive up to 3 "read next" cards from insight_articles (locked decision 4 — // no new collection): same category first (excluding the current piece), then // fill by sort order. Returns null when CMS is unavailable → ArtRelated falls // back to the hardcoded ART_RELATED. function deriveRelated(currentId) { if (!window.cms) return null; const rows = window.cms.get("insight_articles"); if (!Array.isArray(rows) || rows.length === 0) return null; const current = rows.find((r) => r.article_id === currentId); const cat = current && current.category; const others = rows.filter((r) => r.article_id !== currentId); const sameCat = others.filter((r) => r.category === cat); const rest = others.filter((r) => r.category !== cat); const picked = sameCat.concat(rest).slice(0, 3); if (picked.length === 0) return null; return picked.map((r) => { const rt = computeReadTime(r); // auto-computed; read_time field is hidden return { cmsId: r.id, id: r.article_id, cat: ART_CAT_EN[r.category] || String(r.category || "").toUpperCase(), date: fmtArtDate(r.publication_date) || "", read: rt && rt.vi, readEn: rt && rt.en, title: r.title && r.title.vi, titleEn: r.title && r.title.en, img: ART_IMG[r.article_id], }; }); } /* ---------------------------------------------------------- Option B — structured article blocks. Each article is now an individual CMS entity: article.html?id=pX reads insight_articles[pX] + its article_blocks (ordered, typed) and renders them with the exact art-directed markup. Validate ?id (default p1). stat_row blocks carry their numbers in the article_block_stats child collection. ---------------------------------------------------------- */ function getArticleId() { try { const id = new URLSearchParams(window.location.search).get("id"); return id && /^[a-z0-9_-]{1,40}$/i.test(id) ? id : "p1"; } catch (e) { return "p1"; } } // category enum → hero category line + breadcrumb "here" label (reuses the // Insights category wording; not fabricated). Falls back to capital. const ART_CAT = { capital: { line: { vi: "Capital — Đầu tư & Tài sản", en: "Capital — Investment & Assets" }, here: { vi: "Đầu tư & Tài sản", en: "Capital & Assets" } }, transform: { line: { vi: "Transformation — Chuyển đổi & Vận hành", en: "Transformation — Transformation & Operations" }, here: { vi: "Chuyển đổi & Vận hành", en: "Transformation & Operations" } }, academy: { line: { vi: "Academy — Học thuật & Tri thức", en: "Academy — Academy & Knowledge" }, here: { vi: "Học thuật & Tri thức", en: "Academy & Knowledge" } }, }; // Read the ordered blocks for an article (filter by the M2O `article` FK, then // attach each stat_row's numbers from article_block_stats grouped by `block`). // Returns null when CMS/store is unavailable, [] when the article has no blocks. function getArticleBlocks(articleId) { if (!window.cms) return null; const art = window.cms.get("insight_articles", articleId); const all = window.cms.get("article_blocks"); if (!art || !Array.isArray(all)) return null; const blocks = all.filter((b) => b.article === art.id).slice() .sort((a, b) => (a.sort || 0) - (b.sort || 0)); const allStats = window.cms.get("article_block_stats"); return blocks.map((b) => { if (b.block_type !== "stat_row" || !Array.isArray(allStats)) return b; const stats = allStats.filter((s) => s.block === b.id).slice() .sort((a, b) => (a.sort || 0) - (b.sort || 0)); return Object.assign({}, b, { _stats: stats }); }); } // Build the table-of-contents (scroll-spy anchors) from heading blocks. function tocFromBlocks(blocks) { if (!Array.isArray(blocks)) return []; return blocks .filter((b) => b.block_type === "heading" && b.anchor_id) .map((b) => ({ id: b.anchor_id, label: (b.heading_text && b.heading_text.vi) || "", labelEn: (b.heading_text && b.heading_text.en) || "", })); } // Render one block with the exact art-directed markup of the original prose. // body/cite may carry inline // → injected via gemsSanitize. const BLOCK_VE_FIELDS = { paragraph: ["body"], heading: ["heading_text", "heading_num", "anchor_id"], pullquote: ["body", "cite"], figure: ["figure_label", "figure_caption"], note: ["body"], }; function renderArtBlock(b) { const t = (v) => (window.cms && window.cms.t ? window.cms.t(v) : (v && (v.vi || v.en)) || v); const ve = window.veAttr ? window.veAttr("article_blocks", b.id, BLOCK_VE_FIELDS[b.block_type] || ["body"], "drawer") : undefined; switch (b.block_type) { case "heading": return (

{b.heading_num}{t(b.heading_text)}

); case "pullquote": return (

{t(b.cite)}

); case "stat_row": return (
{(b._stats || []).map((s) => ( ))}
); case "figure": return (
{t(b.figure_label)} {t(b.figure_caption)}
); case "note": return (

{t(b.body)}

); case "paragraph": default: return (

); } } /* ---------------------------------------------------------- EDITOR.JS ADAPTER (native Block Editor → art-directed markup) New articles author the body in Directus' native Block Editor, stored as Editor.js JSON: { time, blocks:[{id,type,data}], version }. We render ONLY whitelisted block types with the SAME art-direction as the legacy article_blocks. Inline text is HTML (e.g. ) → ALWAYS routed through gemsSanitize. Unknown block types are skipped (never injected raw). Empty/missing content_blocks → caller falls back to the legacy O2M render, so the existing articles are untouched. ---------------------------------------------------------- */ function ejParse(v) { if (!v) return null; if (typeof v === "string") { try { return JSON.parse(v); } catch (e) { return null; } } return typeof v === "object" ? v : null; } function ejSan(html) { return window.gemsSanitize ? window.gemsSanitize(html == null ? "" : String(html)) : ""; } function ejHeadingId(i) { return "eh-" + i; } function ejStrip(html) { return (html == null ? "" : String(html)).replace(/<[^>]+>/g, ""); } // Editor.js nested-list: items = [{ content, items:[…] }] (recursive). Older // list tool may emit plain strings — handle both. function ejListItems(items) { if (!Array.isArray(items)) return null; return items.map((it, i) => { const content = typeof it === "string" ? it : (it && it.content) || ""; const kids = it && Array.isArray(it.items) && it.items.length ? it.items : null; return (

  • {kids ?
      {ejListItems(kids)}
    : null}
  • ); }); } function renderEditorJsBlock(blk, i, seq) { if (!blk || !blk.type || !blk.data) return null; const d = blk.data; switch (blk.type) { case "header": { // Number only h2 (matches the legacy numbered-heading art-direction). if (d.level === 3 || d.level === 4) { const Tag = d.level === 3 ? "h3" : "h4"; return ; } const num = seq.n < 10 ? "0" + seq.n : "" + seq.n; seq.n += 1; return (

    {num}

    ); } case "paragraph": return

    ; case "quote": return (

    {d.caption ? : null}

    ); case "list": case "nestedlist": { const ListTag = d.style === "ordered" ? "ol" : "ul"; return {ejListItems(d.items)}; } case "image": { const f = d.file; const fid = f && (f.id || (typeof f === "string" ? f : null)); const base = window.cms && window.cms.CMS_URL; const src = fid && base ? base + "/assets/" + fid : (d.url || (f && f.url)); if (!src) return null; return (
    {ejStrip(d.caption)}
    {d.caption ?
    : null}
    ); } case "delimiter": return
    ; default: return null; // unknown type → skipped (never injected raw) } } // Render an Editor.js document. The FIRST paragraph gets the .lead-p drop-cap // (matches the legacy lead-paragraph art-direction). function renderEditorJsBlocks(ej) { if (!ej || !Array.isArray(ej.blocks)) return null; const seq = { n: 1 }; let leadDone = false; return ej.blocks.map((blk, i) => { if (blk && blk.type === "paragraph" && !leadDone) { leadDone = true; const text = blk.data && blk.data.text; return

    ; } return renderEditorJsBlock(blk, i, seq); }).filter(Boolean); } // Scroll-spy TOC from Editor.js header blocks (Editor.js has no anchor ids → // derive a stable one from block index, matching ejHeadingId in the renderer). function tocFromEditorJs(ej) { if (!ej || !Array.isArray(ej.blocks)) return []; const toc = []; ej.blocks.forEach((blk, i) => { if (blk && blk.type === "header") { const label = ejStrip(blk.data && blk.data.text); toc.push({ id: ejHeadingId(i), label, labelEn: label }); } }); return toc; } /* ---------------------------------------------------------- READ-TIME (auto-computed). The Directus `read_time` field is hidden from the authoring form — editors no longer type it. We derive it from the body word count: prefer the native Block Editor JSON (`content_blocks`, per lang), fall back to the legacy typed `article_blocks` O2M for the 9 older pieces. Returns a drop-in {vi,en} shape so callers can swap it for `art.read_time`; null when no body words are found (caller keeps its own fallback string). ---------------------------------------------------------- */ const READ_WPM = 200; // single constant for VI + EN (see plan R4) // Strip inline HTML, decode the few entities the editors produce, count words. function countWords(html) { const text = ejStrip(html).replace(/&[a-z]+;/gi, " ").trim(); if (!text) return 0; return text.split(/\s+/).filter(Boolean).length; } // Editor.js nested-list items are recursive ({content, items:[…]} or strings). function countEjListWords(items) { if (!Array.isArray(items)) return 0; return items.reduce((n, it) => { const content = typeof it === "string" ? it : (it && it.content) || ""; const kids = it && Array.isArray(it.items) ? countEjListWords(it.items) : 0; return n + countWords(content) + kids; }, 0); } // Words across every text-bearing Editor.js block (header/paragraph/quote/list/figure). function countEjWords(ej) { if (!ej || !Array.isArray(ej.blocks)) return 0; return ej.blocks.reduce((n, blk) => { const d = blk && blk.data; if (!d) return n; return n + countWords(d.text) + countWords(d.caption) + countEjListWords(d.items); }, 0); } // Resolve a translatable {vi,en} field for an explicit lang (EN→VI fallback). function pickLang(field, lang) { if (field == null) return field; if (typeof field !== "object") return field; return lang === "en" ? (field.en != null && field.en !== "" ? field.en : field.vi) : field.vi; } // Words across the legacy typed article_blocks (per lang). function countLegacyWords(blocks, lang) { if (!Array.isArray(blocks)) return 0; return blocks.reduce((n, b) => n + countWords(pickLang(b.body, lang)) + countWords(pickLang(b.heading_text, lang)) + countWords(pickLang(b.cite, lang)) + countWords(pickLang(b.figure_caption, lang)), 0); } // Word count for one language: Block Editor first, legacy O2M fallback. function articleWordCount(art, lang) { const ej = ejParse(pickLang(art.content_blocks, lang)); if (ej && Array.isArray(ej.blocks) && ej.blocks.length) return countEjWords(ej); return countLegacyWords(getArticleBlocks(art.article_id), lang); } function minutesFor(words) { return Math.max(1, Math.round(words / READ_WPM)); } // Auto read-time for an article → {vi:"8 phút", en:"8 min"} or null when no body. function computeReadTime(art) { if (!art) return null; const viWords = articleWordCount(art, "vi"); const enWords = articleWordCount(art, "en"); if (viWords <= 0 && enWords <= 0) return null; return { vi: `${minutesFor(viWords || enWords)} phút`, en: `${minutesFor(enWords || viWords)} min`, }; } function ArtHero({ articleId }) { // Clean single-string metadata sourced from the matched featured article // (insight_articles "p1"). Prose stays hardcoded — CMS body is flat plain // text and cannot reproduce the rich inline JSX. Everything falls back to the // hardcoded copy when CMS is unavailable. const art = window.cms ? window.cms.get("insight_articles", articleId) : null; const cmsDate = art && fmtArtDate(art.publication_date); const artDate = cmsDate || "02.06.2026"; const catInfo = ART_CAT[(art && art.category) || "capital"] || ART_CAT.capital; const heroImg = ART_IMG[articleId] || UNSPLASH.classroom; // Byline + meta labels live in page_sections data blobs (drawer-edited). const byline = window.cms ? window.cms.get("article_byline_fields") : null; const meta = window.cms ? window.cms.get("article_meta_fields") : null; const bd = byline || {}; const md = meta || {}; const bylineVe = byline && byline.id && window.veAttr && window.veAttr("article_byline_fields", byline.id, BYLINE_FIELDS, "drawer"); const metaVe = meta && meta.id && window.veAttr && window.veAttr("article_meta_fields", meta.id, META_FIELDS, "drawer"); return (

    {artFld(md.crumb_root, "Góc nhìn", "Insights")} / {L(catInfo.here.vi, catInfo.here.en)}
    {L(catInfo.line.vi, catInfo.line.en)}

    {art && art.title ? L(art.title.vi, art.title.en) : L("Trường học là tài sản không khấu hao", "A school is an asset that never depreciates")}.

    {art && art.excerpt ? L(art.excerpt.vi, art.excerpt.en) : L("Lợi nhuận tài chính của một ngôi trường là có thật — ổn định, bền bỉ, tích luỹ lặng lẽ qua thập niên. Nhưng lợi nhuận sâu nhất lại đến trong im lặng.", "A school's financial return is real — steady, durable, compounding quietly across decades. But its deepest return arrives in silence.")}

    {artFld(bd.author_name, "Nguyễn Minh Quang", "Quang Minh Nguyen")}
    {artFld(bd.author_role, "Thành viên Hội đồng Đầu tư · GEMS", "Investment Committee Member · GEMS")}
    {artDate}
    ); } // Two hero image slots — CSS shows the right one based on .hero-split / .hero-stacked function StackedHeroImage({ img }) { const meta = window.cms ? window.cms.get("article_meta_fields") : null; const md = meta || {}; const metaVe = meta && meta.id && window.veAttr && window.veAttr("article_meta_fields", meta.id, META_FIELDS, "drawer"); return ( <>
    {artFld(md.image_label, "Hình ảnh", "Image")} {L("Lớp học tại một đơn vị trong danh mục GEMS — ảnh minh hoạ", "A classroom at a GEMS portfolio institution — illustrative")}
    ); } function SplitHeroImage({ img }) { return (
    ); } /* ---------------------------------------------------------- RAIL — mục lục + chia sẻ ---------------------------------------------------------- */ function ArtRail({ active, toc = ART_TOC }) { const [copied, setCopied] = React.useState(false); const meta = window.cms ? window.cms.get("article_meta_fields") : null; const md = meta || {}; const metaVe = meta && meta.id && window.veAttr && window.veAttr("article_meta_fields", meta.id, META_FIELDS, "drawer"); const copy = () => { const url = window.location.href; if (navigator.clipboard) navigator.clipboard.writeText(url).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1600); }; return ( ); } /* ---------------------------------------------------------- PROSE — nội dung bài (placeholder, bám luận điểm GEMS 2026) ---------------------------------------------------------- */ function StatCell({ target, suffix = "", prefix = "", decimals = 0, label, ve }) { const ref = useCountUp(target, { suffix, prefix, decimals }); return (
    {prefix}{target}{suffix}
    {label}
    ); } function ArtProse({ articleId, blocks, editorJs }) { // Body is CMS-managed per article. New articles author in the native Block // Editor (Editor.js JSON, `editorJs`) → rendered via renderEditorJsBlocks. // When that is empty we fall back to the legacy typed `article_blocks` (O2M) // so the existing articles render byte-for-byte as before. Both paths share // the same art-direction (numbered h2, pull-quote, figure, lead drop-cap). const ready = useCmsReady(); const meta = window.cms ? window.cms.get("article_meta_fields") : null; const byline = window.cms ? window.cms.get("article_byline_fields") : null; const md = meta || {}; const bd = byline || {}; const metaVe = meta && meta.id && window.veAttr && window.veAttr("article_meta_fields", meta.id, META_FIELDS, "drawer"); const bylineVe = byline && byline.id && window.veAttr && window.veAttr("article_byline_fields", byline.id, BYLINE_FIELDS, "drawer"); const ejBlocks = editorJs && Array.isArray(editorJs.blocks) ? editorJs.blocks : null; const useEjs = !!(ejBlocks && ejBlocks.length); const hasBlocks = useEjs || (Array.isArray(blocks) && blocks.length > 0); // No blocks: before CMS settles render nothing (avoid a flash); once settled, an // article with no body shows a bilingual "coming soon" instead of wrong content. if (!hasBlocks) { return (
    {ready ? (

    {L("Nội dung đang được cập nhật.", "Content coming soon.")}

    ) : null}
    ); } return (
    {useEjs ? renderEditorJsBlocks(editorJs) : blocks.map(renderArtBlock)}
    {artFld(md.end_of_article, "Hết bài", "End of article")}
    {artFld(bd.author_label, "Tác giả", "Author")}

    {artFld(bd.author_name, "Nguyễn Minh Quang", "Quang Minh Nguyen")}

    {artFld(bd.author_bio, "Thành viên Hội đồng Đầu tư của GEMS, tập trung vào chiến lược vốn dài hạn cho các tài sản giáo dục tại Việt Nam và Châu Á. Trước GEMS, ông đã trực tiếp dẫn dắt và tái thiết các định chế học thuật tư nhân.", "An Investment Committee member at GEMS, focused on long-duration capital strategy for education assets across Vietnam and Asia. Before GEMS, he led and rebuilt private academic institutions directly.")}

    ); } /* ---------------------------------------------------------- RELATED + NEWSLETTER ---------------------------------------------------------- */ function ArtRelated({ articleId }) { // heading stays a flat page_sections field; the all-articles link label is // promoted to the article_related_fields singleton (Option B Phase 6, no raw JSON). const rel = window.cms ? window.cms.get("page_sections", "article_related") : null; const relF = window.cms ? window.cms.get("article_related_fields") : null; const relHeadVe = rel && rel.id && window.veAttr && window.veAttr("page_sections", rel.id, "heading", "popover"); const relAllVe = relF && relF.id && window.veAttr && window.veAttr("article_related_fields", relF.id, ["all_link"], "drawer"); // Derive "read next" from insight_articles (decision 4); fall back to ART_RELATED. const cards = deriveRelated(articleId) || ART_RELATED; return (
    ); } function ArtNewsletter() { const news = window.cms ? window.cms.get("page_sections", "article_newsletter") : null; const newsFields = window.cms ? window.cms.get("article_newsletter_fields") : null; const nd = newsFields || {}; const newsHeadVe = news && news.id && window.veAttr && window.veAttr("page_sections", news.id, "heading", "popover"); const newsBodyVe = news && news.id && window.veAttr && window.veAttr("page_sections", news.id, "body", "popover"); const newsDataVe = newsFields && newsFields.id && window.veAttr && window.veAttr("article_newsletter_fields", newsFields.id, NEWS_FIELDS, "drawer"); return (
    {artFld(nd.label, "Góc nhìn hàng tháng", "Monthly Insights")}

    {artFld(news && news.heading, "Nhận phân tích mới nhất từ GEMS", "Get the latest analysis from GEMS")}

    {artFld(news && news.body, "Một email mỗi tháng — phân tích về đầu tư giáo dục, chuyển đổi định chế và kiến tạo tri thức. Không spam.", "One email a month — analysis on education investment, institutional transformation and knowledge-building. No spam.")}

    e.preventDefault()}>
    {artFld(nd.note, "Mỗi tháng một email. Hủy bất cứ lúc nào.", "One email a month. Unsubscribe anytime.")}
    ); } /* ---------------------------------------------------------- PAGE ---------------------------------------------------------- */ function ArticlePage({ hero, body, dropcap }) { // Which article? Read ?id (validated); the body comes from CMS article_blocks. const articleId = getArticleId(); const blocks = getArticleBlocks(articleId); // New articles: read the native Block Editor JSON (per-language) from the // article's translations. cms.t picks the lang object (EN→VI fallback built in). const art = window.cms ? window.cms.get("insight_articles", articleId) : null; const editorJs = art && window.cms ? ejParse(window.cms.t(art.content_blocks)) : null; const useEjs = !!(editorJs && Array.isArray(editorJs.blocks) && editorJs.blocks.length); // TOC: prefer Block Editor headers, then legacy heading blocks; fall back to the // static ART_TOC only for p1 before CMS settles so the rail doesn't flash empty. const toc = useEjs ? tocFromEditorJs(editorJs) : (blocks && blocks.length ? tocFromBlocks(blocks) : (articleId === "p1" ? ART_TOC : [])); const active = useScrollSpy(toc.length ? toc.map((t) => t.id) : ["_none"]); return (