// hooks.jsx — shared hooks + tiny utilities const { useState, useEffect, useRef, useCallback, createContext, useContext } = React; const SiteCtx = createContext(null); // IntersectionObserver-driven reveal hook function useReveal(opts = {}) { const ref = useRef(null); const [shown, setShown] = useState(false); useEffect(() => { if (!ref.current) return; if (!("IntersectionObserver" in window)) { setShown(true); return; } const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { setShown(true); io.disconnect(); } }); }, { threshold: opts.threshold || 0.15, rootMargin: opts.rootMargin || "0px" }); io.observe(ref.current); return () => io.disconnect(); }, []); return [ref, shown]; } // Tracks current page based on scroll position within snap-root function useCurrentPage(rootRef, pageIds) { const [current, setCurrent] = useState(pageIds[0]); useEffect(() => { const root = rootRef.current; if (!root) return; const onScroll = () => { const y = root.scrollTop + window.innerHeight / 2; let best = pageIds[0]; for (const id of pageIds) { const el = document.getElementById(id); if (!el) continue; const top = el.offsetTop; if (y >= top) best = id; } setCurrent(best); }; onScroll(); root.addEventListener("scroll", onScroll, { passive: true }); return () => root.removeEventListener("scroll", onScroll); }, [rootRef, pageIds.join(",")]); return current; } // CountUp number with ease-out function CountUp({ target, duration = 1100, suffix = "", motion = true }) { const [val, setVal] = useState(motion ? 0 : target); const ref = useRef(null); const [started, setStarted] = useState(!motion); useEffect(() => { if (!ref.current || !motion) return; const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { setStarted(true); io.disconnect(); } }); }, { threshold: 0.4 }); io.observe(ref.current); return () => io.disconnect(); }, [motion]); useEffect(() => { if (!started || !motion) { setVal(target); return; } const start = performance.now(); let raf; const tick = (t) => { const p = Math.min(1, (t - start) / duration); const eased = 1 - Math.pow(1 - p, 3); setVal(Math.round(target * eased)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [started, target, duration, motion]); const formatted = val >= 1000 ? val.toLocaleString() : val.toString(); return {formatted}{suffix}; } // I18N (kept minimal, EN/ZH for nav + key copy) const I18N = { en: { nav_home: "Home", nav_cases: "Case Studies", nav_projects: "Projects", nav_resume: "Resume", nav_chat: "Ask AI", lang: "中", home_eyebrow: "AI Product Manager · Available 2026", home_tagline_a: "I build things ", home_tagline_b: "and evaluate", home_tagline_c: " them.", home_sub: "AI Product Manager at Chinadaas, working on qibook.com (a B2B business-intelligence product for banks and investors). I write about evals, agent workflows, and the side projects I ship after work.", cta_cases: "Read the case studies", cta_chat: "Ask my AI clone →", scroll_hint: "Scroll", cases_eyebrow: "Case Studies", cases_title: "Seven projects, seven writeups.", cases_sub: "Each one hit something the plan didn't account for. These are the post-mortems.", projects_eyebrow: "Side Projects", projects_title: "Things I built after work.", projects_sub: "Small tools I made for myself — prototype, ship to Vercel, see what comes back. Click to open.", resume_eyebrow: "Resume", resume_title: "One page. Print or download.", resume_sub: "Same content as candidate.md, formatted for paper.", download: "Download PDF", print: "Print", chat_eyebrow: "Ask AI", chat_title: "Talk to my AI clone.", chat_sub: "It's read everything on this site and answers in my voice. Pick a frame — CEO, HR, or founder — to tilt the answer toward what you care about.", chat_persona_ceo_name: "CEO Mode", chat_persona_ceo_desc: "Strategic fit, business impact, ROI.", chat_persona_hr_name: "HR Mode", chat_persona_hr_desc: "Culture, communication, growth trajectory.", chat_persona_founder_name: "Founder Mode", chat_persona_founder_desc: "Builder energy, 0→1 instinct, urgency.", chat_suggestions: "Suggested questions", chat_placeholder: "Ask why you should hire Ming…", chat_send: "Send", fab: "Why hire me?", }, zh: { nav_home: "首页", nav_cases: "案例", nav_projects: "Demo", nav_resume: "简历", nav_chat: "AI 对话", lang: "EN", home_eyebrow: "AI 产品经理 · 2026 可入职", home_tagline_a: "炼", home_tagline_b: "创造", home_tagline_c: ",也炼评估", home_sub: "AI 产品经理。最近在中数智汇做 qibook.com——一款服务银行和投资机构的 B2B 商业智能产品。这里记录我做过的项目、写过的复盘,和下班时间搭的一些小东西。", cta_cases: "看完整案例 →", cta_chat: "和我的 AI 分身聊聊 →", scroll_hint: "向下滚", cases_eyebrow: "案例复盘", cases_title: "七个项目,七篇复盘。", cases_sub: "每一个都撞到了计划里没提过的东西。这是事后复盘。", projects_eyebrow: "Side Project", projects_title: "下班搭的小东西。", projects_sub: "自己用着方便做的几个小工具——做原型,上 Vercel,看数据。点开看。", resume_eyebrow: "简历", resume_title: "一页纸。打印或下载。", resume_sub: "和 candidate.md 内容一致,按 A4 排版。", download: "下载 PDF", print: "打印", chat_eyebrow: "AI 对话", chat_title: "和我的 AI 分身聊。", chat_sub: "它读过这个站点的所有内容,用我的口吻回答。选一个视角——CEO、HR、或者创始人——能让回答更贴近你想了解的方向。", chat_persona_ceo_name: "CEO 模式", chat_persona_ceo_desc: "战略契合度、业务影响、ROI。", chat_persona_hr_name: "HR 模式", chat_persona_hr_desc: "文化、沟通、成长轨迹。", chat_persona_founder_name: "创始人模式", chat_persona_founder_desc: "Builder 能量、0→1 直觉、紧迫感。", chat_suggestions: "建议问题", chat_placeholder: "问问为什么应该雇慕铭……", chat_send: "发送", fab: "为什么雇我?", }, }; window.MM_HOOKS = { SiteCtx, useReveal, useCurrentPage, CountUp, I18N };