/* CITTA BUILDERS — shared React components used across all five pages.
Loaded after the Cohere DS bundle; exports onto window.Site so each page's
own babel script (separate scope) can use them. */
/* Single source of truth for footer social links — update these three URLs to go live. */
const SOCIAL_LINKS = {
linkedin: 'https://www.linkedin.com/in/citta-builders-365b54424',
instagram: 'https://www.instagram.com/cittabuilders/',
facebook: 'https://www.facebook.com/cittabuilders360',
};
const NAV_LINKS = [
{ href: 'index.html', label: 'Home', i18n: 'nav.home' },
{ href: 'about.html', label: 'About', i18n: 'nav.about' },
{ href: 'services.html', label: 'Services', i18n: 'nav.services' },
{ href: 'portfolio.html', label: 'Portfolio', i18n: 'nav.portfolio' },
{ href: 'contact.html', label: 'Contact', i18n: 'nav.contact' },
];
/* ============ Language switcher — instant, local, dictionary-based (shared/i18n.js). No network
call, no external widget, no polling: choosing a language is a synchronous DOM text swap via
CITTA_I18N, so there is no delay and nothing to get "stuck". Persisted in localStorage so the
choice survives page navigation across the whole site. ============ */
const LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'sw', label: 'Kiswahili' },
{ code: 'fr', label: 'Français' },
{ code: 'ar', label: 'العربية' },
{ code: 'zh-CN', label: '中文' },
{ code: 'pt', label: 'Português' },
];
function getCurrentLang() {
return (window.CITTA_I18N && window.CITTA_I18N.getLang()) || 'en';
}
function LanguageSwitcher({ variant, onLight }) {
const [open, setOpen] = React.useState(false);
const [current, setCurrent] = React.useState('en');
React.useEffect(() => {
setCurrent(getCurrentLang());
const onChange = (e) => setCurrent(e.detail.lang);
document.addEventListener('citta-lang-changed', onChange);
return () => document.removeEventListener('citta-lang-changed', onChange);
}, []);
const choose = (code) => {
setOpen(false);
if (window.CITTA_I18N) window.CITTA_I18N.setLang(code);
setCurrent(code);
};
return (
{open && (
setOpen(false)}>
{LANGUAGES.map((l) => (
))}
)}
);
}
/* ============ Device-mode switch — removed: real responsive breakpoints (mobile/desktop) now
handle this automatically, so the manual Desktop/Mobile toggle is no longer needed. ============ */
function Nav({ active, alwaysLight = false }) {
const [scrolled, setScrolled] = React.useState(alwaysLight);
const [ready, setReady] = React.useState(false);
const [hovered, setHovered] = React.useState(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [indicator, setIndicator] = React.useState({ left: 0, width: 0, ready: false });
const linksRef = React.useRef(null);
const linkRefs = React.useRef({});
React.useEffect(() => {
if (alwaysLight) return;
const onScroll = () => setScrolled(window.scrollY > 40);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [alwaysLight]);
React.useEffect(() => {
const t = setTimeout(() => setReady(true), 50);
return () => clearTimeout(t);
}, []);
const measure = React.useCallback((label) => {
const el = linkRefs.current[label];
const wrap = linksRef.current;
if (!el || !wrap) return;
const elRect = el.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
setIndicator({ left: elRect.left - wrapRect.left, width: elRect.width, ready: true });
}, []);
React.useEffect(() => {
measure(hovered || active);
const onResize = () => measure(hovered || active);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [hovered, active, measure, scrolled]);
const { Button } = window.CohereDesignSystem_4a3e6b;
const onLight = scrolled || alwaysLight;
const logoSrc = onLight ? 'images/logo-black.png' : 'images/logo-white.png';
return (
);
}
function Footer() {
const ref = React.useRef(null);
const [shown, setShown] = React.useState(false);
const [subscribed, setSubscribed] = React.useState(false);
const [subError, setSubError] = React.useState(null);
const [subSending, setSubSending] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => { entries.forEach((e) => { if (e.isIntersecting) { setShown(true); io.unobserve(el); } }); },
{ threshold: 0.1 }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
);
}
/* Runs lucide.createIcons after mount — call once per page from the root component. */
function useLucide(deps = []) {
React.useEffect(() => {
if (window.lucide) window.lucide.createIcons();
}, deps);
}
function PageHero({ eyebrow, title, sub, surface = 'primary', children, tall = false, bgImage, i18nEyebrow, i18nTitle, i18nSub }) {
return (
{bgImage ? (
) : (
)}
{eyebrow &&
{eyebrow}}
{title}
{sub &&
{sub}
}
{children}
);
}
function SectionHead({ eyebrow, title, body, action, i18nEyebrow, i18nTitle, i18nBody }) {
return (
{eyebrow &&
{eyebrow}}
{title}
{body &&
{body}
}
{action}
);
}
function ImgSlot({ ratio = '4-3', label = 'Image placeholder', src, alt, fit }) {
if (src) {
return (
);
}
return (
{label}
);
}
function StatGrid({ stats, onDark = false }) {
return (
);
}
/* ============ LedgerStats — unique count-up scoreboard for the "Why Choose" band ============ */
function LedgerStat({ value, label, index, i18n, i18nValue }) {
const ref = React.useRef(null);
const [display, setDisplay] = React.useState(null);
const match = /^(\d+)(.*)$/.exec(value);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
if (!match) { setDisplay(value); return; }
const target = parseInt(match[1], 10);
const suffix = match[2];
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
io.unobserve(el);
const duration = 1300;
const start = performance.now();
const tick = (now) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
setDisplay(Math.round(eased * target) + suffix);
if (t < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}, { threshold: 0.15, rootMargin: '0px 0px -10% 0px' });
io.observe(el);
return () => io.disconnect();
}, [value]);
return (
{display === null ? (match ? '0' + match[2] : value) : display}
{label}
);
}
function LedgerStats({ stats }) {
return (
{stats.map((s, i) => )}
);
}
function Timeline({ items }) {
return (
{items.map((it, i) => (
{it.index || String(i + 1).padStart(2, '0')}
))}
);
}
function ProcessGrid({ steps }) {
const railRef = React.useRef(null);
const [active, setActive] = React.useState(0);
React.useEffect(() => {
const rail = railRef.current;
if (!rail) return;
const cards = Array.from(rail.children);
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio > 0.6) {
setActive(cards.indexOf(entry.target));
}
});
},
{ root: rail, threshold: [0.6] }
);
cards.forEach((c) => io.observe(c));
return () => io.disconnect();
}, [steps]);
return (
{steps.map((s, i) => (
{String(i + 1).padStart(2, '0')}
{s.title}
{s.body}
))}
{steps.map((s, i) => )}
Swipe to see all {steps.length} steps
);
}
function Testimonials({ items }) {
return (
{items.map((t, i) => (
“{t.quote}”
{t.name}{t.role}
))}
);
}
function IndustryChips({ items }) {
const { Chip } = window.CohereDesignSystem_4a3e6b;
return (
{items.map((i) => {i})}
);
}
function CtaBand({ eyebrow, title, sub, surface = 'green', i18nEyebrow, i18nTitle, i18nSub, i18nCta }) {
const { Button } = window.CohereDesignSystem_4a3e6b;
const ref = React.useRef(null);
const [shown, setShown] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => { entries.forEach((e) => { if (e.isIntersecting) { setShown(true); io.unobserve(el); } }); },
{ threshold: 0.25 }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
);
}
Object.assign(window, {
Site: { Nav, Footer, useLucide, PageHero, SectionHead, ImgSlot, StatGrid, LedgerStats, Timeline, ProcessGrid, Testimonials, IndustryChips, CtaBand },
});