/* Shared hooks + small components (attached to window) */
const { useState, useEffect, useRef } = React;
/* Reveal-on-scroll wrapper */
const Reveal = ({ as: Tag = "div", delay = 0, className = "", children, ...p }) => {
const ref = useRef(null);
const [show, setShow] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([e]) => { if (e.isIntersecting) { setShow(true); io.disconnect(); } },
{ threshold: 0.12, rootMargin: "0px 0px -40px 0px" }
);
io.observe(el);
return () => io.disconnect();
}, []);
const d = delay ? ` d${delay}` : "";
return {children};
};
/* Count-up number when visible */
const CountUp = ({ value }) => {
const ref = useRef(null);
const [txt, setTxt] = useState("0");
useEffect(() => {
const el = ref.current;
if (!el) return;
// parse number, keep separators
const target = parseFloat(value.replace(/[^\d,.-]/g, "").replace(/\./g, "").replace(",", ".")) || 0;
const io = new IntersectionObserver(([e]) => {
if (!e.isIntersecting) return;
io.disconnect();
const dur = 1400, t0 = performance.now();
const fmt = (n) => {
const r = Math.round(n);
return value.includes(".") ? r.toLocaleString("id-ID") : String(r);
};
const tick = (t) => {
const k = Math.min(1, (t - t0) / dur);
const e2 = 1 - Math.pow(1 - k, 3);
setTxt(fmt(target * e2));
if (k < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}, { threshold: 0.4 });
io.observe(el);
return () => io.disconnect();
}, [value]);
return {txt};
};
window.Reveal = Reveal;
window.CountUp = CountUp;
/* Paginated list controller: returns {page, setPage, pageItems, pages, total, range} */
const usePaged = (items, perPage, deps = []) => {
const [page, setPage] = useState(1);
const pages = Math.max(1, Math.ceil(items.length / perPage));
useEffect(() => { setPage(1); }, deps); // reset when deps (e.g. filter) change
const safe = Math.min(page, pages);
const start = (safe - 1) * perPage;
const pageItems = items.slice(start, start + perPage);
return { page: safe, setPage, pageItems, pages, total: items.length, from: items.length ? start + 1 : 0, to: Math.min(start + perPage, items.length) };
};
/* Pager UI with windowed page numbers + ellipses */
const Pager = ({ page, pages, onChange }) => {
if (pages <= 1) return null;
const nums = [];
for (let i = 1; i <= pages; i++) {
if (i === 1 || i === pages || Math.abs(i - page) <= 1) nums.push(i);
else if (nums[nums.length - 1] !== "…") nums.push("…");
}
const goto = (n) => { onChange(n); };
return (
{nums.map((n, i) => n === "…"
? …
:
)}
);
};
window.usePaged = usePaged;
window.Pager = Pager;