// Shared chrome: header, CTA band, footer, and small helpers. // Globals: Header, Footer, CallToAction, Brand, BrandMark, Icon, NAV_ITEMS, getLang, setLang, formatRp, formatRpFull, // getJSON, getSettings, fmtDate, waLink, igHandle, Progress, ProgramCard, sortPrograms, CATEGORY_ID function formatRp(n) { n = Number(n) || 0; if (n >= 1e9) return 'Rp ' + (n/1e9).toFixed(n % 1e9 === 0 ? 0 : 1).replace('.', ',') + ' M'; if (n >= 1e6) return 'Rp ' + (n/1e6).toFixed(n % 1e6 === 0 ? 0 : 1).replace('.', ',') + ' jt'; if (n >= 1e3) return 'Rp ' + Math.round(n/1e3) + ' rb'; return 'Rp ' + n; } function formatRpFull(n) { return 'Rp ' + Math.round(Number(n) || 0).toLocaleString('id-ID'); } const NAV_ITEMS = [ { id: 'home', href: 'index.html', label: { en: 'Home', id: 'Beranda' } }, { id: 'causes', href: 'causes.html', label: { en: 'Causes', id: 'Program' } }, { id: 'events', href: 'events.html', label: { en: 'Events', id: 'Acara' } }, { id: 'blog', href: 'blog.html', label: { en: 'Blog', id: 'Cerita' } }, { id: 'contact', href: 'contact.html', label: { en: 'Contact', id: 'Kontak' } }, ]; const I18N_LANG_KEY = 'duatangan_lang'; function getLang() { return 'id'; } function setLang(l) { try { localStorage.setItem(I18N_LANG_KEY, l); } catch {} window.dispatchEvent(new CustomEvent('lang-change', { detail: l })); } // One request per URL per page load; several sections share settings/stats. const _jsonCache = {}; function getJSON(url) { if (!_jsonCache[url]) { _jsonCache[url] = fetch(url).then(r => { if (!r.ok) throw new Error(r.status); return r.json(); }); } return _jsonCache[url]; } function getSettings() { return getJSON('/api/settings.php').catch(() => ({})); } function fmtDate(s, opts = { day: 'numeric', month: 'short', year: 'numeric' }) { if (!s) return ''; const d = new Date(String(s).replace(' ', 'T')); return isNaN(d) ? s : d.toLocaleDateString('id-ID', opts); } // WhatsApp number from settings ("0812…", "+62 812…") → wa.me link function waLink(num) { let digits = String(num || '').replace(/\D/g, ''); if (!digits) return ''; if (digits.startsWith('0')) digits = '62' + digits.slice(1); return 'https://wa.me/' + digits; } function igHandle(url) { const m = String(url || '').match(/instagram\.com\/([^/?#]+)/i); return m ? '@' + m[1] : (url || ''); } function Icon({ name, size = 18, stroke = 1.8, ...rest }) { const common = { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: stroke, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, ...rest, style: { width: size, height: size, flex: 'none', ...rest.style }, }; switch (name) { case 'arrow-right': return ; case 'arrow-left': return ; case 'heart': return ; case 'check': return ; case 'doc': return ; case 'pin': return ; case 'phone': return ; case 'mail': return ; case 'instagram': return ; case 'whatsapp': return ; case 'tiktok': return ; case 'calendar': return ; case 'clock': return ; case 'alert': return ; case 'download': return ; default: return ; } } function BrandMark({ size = 40 }) { return ; } function Brand() { return Dua Tangan; } function Header({ active = 'home' }) { return (
{/* DONATE BUTTON HIDDEN — restore as Donasi */} Hubungi kami
); } function CallToAction() { const [s, setS] = React.useState({}); React.useEffect(() => { getSettings().then(setS); }, []); const wa = waLink(s.whatsapp); return (

Ingin ikut membantu?

Hubungi kami untuk berdonasi, mengirim barang, atau menjadi relawan. Setiap bantuan dicatat dan dilaporkan di halaman program.

{wa && WhatsApp} Hubungi kami
); } function Footer() { const [s, setS] = React.useState({}); React.useEffect(() => { getSettings().then(setS); }, []); const wa = waLink(s.whatsapp); return ( ); } // True once the element has scrolled into view (always true without IntersectionObserver) function useInView(threshold = 0.25) { const ref = React.useRef(null); const [seen, setSeen] = React.useState(false); React.useEffect(() => { const el = ref.current; if (!el || !('IntersectionObserver' in window)) { setSeen(true); return; } const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setSeen(true); io.disconnect(); } }, { threshold }); io.observe(el); return () => io.disconnect(); }, []); return [ref, seen]; } const REDUCED_MOTION = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; // Counts from 0 to value once visible function CountUp({ value, format = n => n, duration = 1400 }) { const [ref, seen] = useInView(0.4); const [n, setN] = React.useState(REDUCED_MOTION ? value : 0); React.useEffect(() => { if (!seen || REDUCED_MOTION) { if (REDUCED_MOTION) setN(value); return; } let raf, start; const tick = t => { if (!start) start = t; const k = Math.min(1, (t - start) / duration); setN(Math.round(value * (1 - Math.pow(1 - k, 4)))); if (k < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [seen, value]); return {format(n)}; } // Single-select filter pills. options: [{ value, label, count? }] function Pills({ options, value, onChange, label }) { return (
{options.map(o => ( ))}
); } function Progress({ raised, goal }) { const pct = goal > 0 ? Math.min(100, Math.round((raised / goal) * 100)) : 0; const [ref, seen] = useInView(0.5); return (
{formatRpFull(raised)} dari {formatRpFull(goal)} {pct}%
); } function StoryCard({ p }) { return (
{p.image &&
}
{p.category && {p.category}}

{p.title}

{p.excerpt &&

{p.excerpt}

}

{fmtDate(p.published_at, { day: 'numeric', month: 'long', year: 'numeric' })}

); } // Photo carousel: native scroll-snap (swipe / trackpad / shift-wheel), plus arrows, dots and ←/→ keys. // One image renders as a plain picture with no controls. function Carousel({ images, className = '', label = 'Foto' }) { const track = React.useRef(null); const [i, setI] = React.useState(0); const n = images.length; const go = k => { const el = track.current; const to = Math.max(0, Math.min(n - 1, k)); el.scrollTo({ left: to * el.clientWidth, behavior: REDUCED_MOTION ? 'auto' : 'smooth' }); }; const onScroll = () => { const el = track.current; setI(Math.round(el.scrollLeft / el.clientWidth)); }; const onKey = e => { if (e.key === 'ArrowLeft') { e.preventDefault(); go(i - 1); } if (e.key === 'ArrowRight') { e.preventDefault(); go(i + 1); } }; if (!n) return null; if (n === 1) return
; return (
{images.map((src, k) => (
))}
{images.map((_, k) =>
{i + 1} / {n}
); } const EVENT_STATUS = { upcoming: 'Akan datang', ongoing: 'Sedang berlangsung', completed: 'Selesai', cancelled: 'Dibatalkan' }; // event_date is a DATETIME string; returns date/time labels or null when it is missing/invalid function eventWhen(e) { const d = new Date(String(e.event_date || '').replace(' ', 'T')); if (isNaN(d)) return null; return { d, day: d.getDate(), month: d.toLocaleDateString('id-ID', { month: 'short' }), date: d.toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }), time: d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }), }; } // One event in a list (events page, "acara lainnya"); the whole card opens event.html function EventRow({ e }) { const w = eventWhen(e); return (
  • {e.title}

    {w && `${w.date}, ${w.time}`} {e.location && ` · ${e.location}`} {e.status !== 'upcoming' && ` · ${EVENT_STATUS[e.status] || e.status}`}

    {e.description &&

    {e.description}

    }
    {e.image && }
  • ); } const CATEGORY_ID = { Infrastructure: 'Infrastruktur', Education: 'Pendidikan', Emergency: 'Darurat', Recurring: 'Rutin', Other: 'Lainnya' }; // Running programs first, then past-deadline, then completed function sortPrograms(list) { const rank = c => c.status === 'completed' ? 2 : (c.deadline && new Date(c.deadline) < new Date() ? 1 : 0); return [...list].sort((a, b) => rank(a) - rank(b) || (a.sort_order || 0) - (b.sort_order || 0)); } function ProgramCard({ c }) { const raised = parseFloat(c.raised_amount) || 0; const goal = parseFloat(c.goal_amount) || 0; const done = c.status === 'completed'; return (
    {c.image ?
    :
    {formatRp(raised)}{goal > 0 ? `terkumpul dari ${formatRp(goal)}` : 'terkumpul'}
    }
    {done ? Selesai : {CATEGORY_ID[c.category] || c.category}} {c.location && {c.location}}

    {c.title}

    {c.description &&

    {c.description}

    }
    {goal > 0 ? :

    {formatRpFull(raised)} terkumpul

    }
    ); } Object.assign(window, { Header, Footer, CallToAction, Brand, BrandMark, Icon, NAV_ITEMS, getLang, setLang, formatRp, formatRpFull, getJSON, getSettings, fmtDate, waLink, igHandle, Progress, ProgramCard, sortPrograms, CATEGORY_ID, useInView, CountUp, Pills, StoryCard, EventRow, EVENT_STATUS, eventWhen, Carousel, });