// 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 (
);
}
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.
);
}
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.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 (
{w && <>{w.day}{w.month}>}
{w && `${w.date}, ${w.time}`}
{e.location && ` · ${e.location}`}
{e.status !== 'upcoming' && ` · ${EVENT_STATUS[e.status] || e.status}`}
{e.description &&
{e.description}
}
Lihat detail acara
{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.description &&
{c.description}
}
{goal > 0 ?
:
{formatRpFull(raised)} terkumpul
}
Lihat catatan program
);
}
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,
});