/* Shared hooks, icons, and small components */
const { useState, useEffect, useRef, useCallback } = React;
/* ---- Hook: reveal on scroll (scroll-math based, robust) ---- */
function useReveal(options) {
const ref = useRef(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const trigger = (options && options.trigger) || 0.9; // reveal when top crosses 90% of viewport
let raf = 0;
let done = false;
const check = () => {
raf = 0;
if (done) return;
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight * trigger && r.bottom > 0) {
done = true;
setShown(true);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
}
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(check); };
check();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, []);
return [ref, shown];
}
/* ---- Reveal wrapper ---- */
function Reveal({ children, delay = 0, as = "div", className = "", style = {}, ...rest }) {
const [ref, shown] = useReveal();
const Tag = as;
return (
{children}
);
}
/* ---- Hook: animated counter when in view ---- */
function useCountUp(target, duration = 1600) {
const ref = useRef(null);
const [val, setVal] = useState(0);
const started = useRef(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
let raf = 0;
const begin = () => {
const start = performance.now();
const tick = (now) => {
const p = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3);
setVal(Math.round(eased * target));
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
};
const check = () => {
raf = 0;
if (started.current) return;
const r = el.getBoundingClientRect();
if (r.top < window.innerHeight * 0.85 && r.bottom > 0) {
started.current = true;
begin();
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
}
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(check); };
check();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, [target, duration]);
return [ref, val];
}
/* ---- Hook: rotating role text with typing ---- */
function useTypewriter(words, { typeSpeed = 80, deleteSpeed = 40, hold = 1600 } = {}) {
const [text, setText] = useState("");
const [idx, setIdx] = useState(0);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const full = words[idx % words.length];
let timeout;
if (!deleting && text === full) {
timeout = setTimeout(() => setDeleting(true), hold);
} else if (deleting && text === "") {
setDeleting(false);
setIdx((i) => (i + 1) % words.length);
} else {
timeout = setTimeout(() => {
setText((t) =>
deleting ? full.slice(0, t.length - 1) : full.slice(0, t.length + 1)
);
}, deleting ? deleteSpeed : typeSpeed);
}
return () => clearTimeout(timeout);
}, [text, deleting, idx, words]);
return text;
}
/* ---- Section heading ---- */
function SectionHeading({ eyebrow, title, lead, align = "left" }) {
return (
{eyebrow}
{lead && {lead}
}
);
}
/* ---- Icons ---- */
const Icon = {
arrow: (p) => (),
download: (p) => (),
mail: (p) => (),
github: (p) => (),
linkedin: (p) => (),
map: (p) => (),
bolt: (p) => (),
layers: (p) => (),
server: (p) => (),
database: (p) => (),
cloud: (p) => (),
cap: (p) => (),
award: (p) => (),
};
const skillIconFor = (group) => ({
"Frontend": Icon.layers,
"Backend & APIs": Icon.server,
"Data": Icon.database,
"Cloud & DevOps": Icon.cloud,
}[group] || Icon.bolt);
Object.assign(window, { useReveal, Reveal, useCountUp, useTypewriter, SectionHeading, Icon, skillIconFor });