/* ===== Подари вторую жизнь — shared components ===== */ const { useState, useEffect, useRef, useContext, createContext, useMemo, useCallback } = React; const PF = window.PF; const NavCtx = createContext({ route:{name:'home'}, go:()=>{}, favs:[], toggleFav:()=>{} }); const useNav = () => useContext(NavCtx); /* ---- Brand / logo ---- */ function Brand({ onClick, light, slogan }) { return (
Подари вторую жизнь {slogan && {PF.SLOGAN}}
); } const SpeciesIcon = ({ species, ...p }) => species==='cat' ? : ; /* count-up number (для «подкручивающихся» процентов) */ function CountUp({ to, dur=850 }){ const [v,setV] = useState(0); useEffect(()=>{ let raf, start, done=false; const tick=(t)=>{ if(!start) start=t; const p=Math.min(1,(t-start)/dur); const e=1-Math.pow(1-p,3); setV(Math.round(to*e)); if(p<1) raf=requestAnimationFrame(tick); else { done=true; setV(to); } }; raf=requestAnimationFrame(tick); const fb=setTimeout(()=>{ if(!done) setV(to); }, dur+500); // гарантия финального значения return ()=>{ cancelAnimationFrame(raf); clearTimeout(fb); }; },[to,dur]); return v; } const SexMark = ({ sex }) => ( {sex==='male' ? : } ); /* ---- Animal card ---- */ function AnimalCard({ a, match, wait, style }) { const { go, favs, toggleFav } = useNav(); const [loaded, setLoaded] = useState(false); const [err, setErr] = useState(false); const on = favs.includes(a.id); const hi = match != null && match >= 90; return (
go('detail',{id:a.id})}>
{!loaded && !err &&
} {err ?
: {a.name}setLoaded(true)} onError={()=>setErr(true)} style={{opacity:loaded?1:0,transition:'opacity .4s'}}/>} {match!=null && %} {wait && match==null && {PF.waitLabel(a.waiting)}} {PF.speciesOne(a.species)}
{a.name}
{a.ageText} {PF.sizeLabel(a.size)} {PF.colorLabel(a.color)}
{a.loc.split(',')[0]}
); } function colorBg(id){ const c=PF.COLORS.find(x=>x.id===id); return c?c.hex:'#ccc'; } /* ---- Skeleton card ---- */ function SkeletonCard(){ return (
); } const SkeletonGrid = ({ n=8 }) => (
{Array.from({length:n}).map((_,i)=>)}
); /* ---- Empty / error ---- */ function EmptyState({ icon, title, text, action }){ const I = icon || Icons.search; return (

{title}

{text}

{action}
); } /* ---- Header (desktop nav) ---- */ function Header(){ const { route, go } = useNav(); const is = (n)=> route.name===n ? 'active':''; return (
); } /* ---- Bottom nav (mobile) ---- */ function BottomNav(){ const { route, go } = useNav(); const items = [ { n:'home', label:'Главная', Ico:Icons.home }, { n:'photo', label:'По фото', Ico:Icons.camera }, { n:'filter', label:'Подбор', Ico:Icons.tune }, { n:'second', label:'Шанс', Ico:Icons.heart }, { n:'catalog', label:'Каталог', Ico:Icons.grid }, ]; return ( ); } /* ---- Footer ---- */ function Footer(){ const { go } = useNav(); return ( ); } /* ---- small bits ---- */ const Stat = ({ value, label }) => (
{value}{label}
); Object.assign(window, { NavCtx, useNav, Brand, SpeciesIcon, SexMark, AnimalCard, colorBg, CountUp, SkeletonCard, SkeletonGrid, EmptyState, Header, BottomNav, Footer, Stat, });