);
}
/* ----------------------------------------------------------
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 (
);
}
// 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 (
);
}
/* ----------------------------------------------------------
GEMS Symbol — minified inline SVG (so we can re-color/animate)
---------------------------------------------------------- */
function GemsSymbol({ size = 64, color = "currentColor", className = "" }) {
return (
);
}
/* 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 (
);
}
/* 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 (
);
}
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 (