/* @jsx React.createElement */ /* ============================================================ INDIE Music School & Studio — single-file site React 18 UMD + Babel standalone. No build step. All components + DEFAULT_CONTENT + Supabase CMS loader. ============================================================ */ const { useState, useEffect, useRef, useCallback } = React; /* ───────────────────────────────────────────── Config ───────────────────────────────────────────── */ const SUPABASE_URL = 'https://indie.supabase.co'; const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Indsc3pqdnZveXltd2dwbXRnd2V4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQ1ODg5OTcsImV4cCI6MjA5MDE2NDk5N30.5emUfc2jlLxutLhtGZFD0Rq3MOUl4JAcU3Fd5RydjRU'; const CONTENT_TABLE = 'ims_website_content'; const FETCH_TIMEOUT = 3000; const WA_NUMBER = '628998267999'; const WA_DISPLAY = '+62 899-8267-999'; function waLink(text) { const base = 'https://wa.me/' + WA_NUMBER; return text ? base + '?text=' + encodeURIComponent(text) : base; } /* ───────────────────────────────────────────── DEFAULT_CONTENT — the site renders from this instantly. CMS rows deep-merge over these values. ───────────────────────────────────────────── */ const DEFAULT_CONTENT = { hero: { eyebrow: 'INDIE Music · School & Studio', title: 'Happiness starts here.', subtitle: 'Sekolah musik anti-mainstream di Gading Serpong. Studio yang estetik, guru yang asik, dan cara belajar yang interaktif — bikin musik nggak lagi kayak beban, tapi jadi happiness yang dipancarkan tiap minggu.', cta_primary: { label: 'Coba kelas gratis', url: waLink('Halo IMS! Aku mau coba kelas gratis dong.') }, cta_secondary: { label: 'Lihat metodenya', url: '#method' }, video_url: '', slides: [ { image: 'assets/photos/hero-band.jpg', eyebrow: 'INDIE Music · School & Studio', title: 'Happiness starts here.', subtitle: 'Sekolah musik anti-mainstream di Gading Serpong. Studio yang estetik, guru yang asik, dan cara belajar yang interaktif — bikin musik nggak lagi kayak beban.', typing: true, }, { image: 'assets/photos/hero-drum-kid.jpg', eyebrow: 'Kurikulum anti-mainstream', title: 'Main musik, bukan dihafalin teori.', subtitle: 'Dari minggu pertama kamu udah main lagu beneran — teori nyusul sambil jalan, bukan jadi gerbang.', typing: true, }, { image: 'assets/photos/hero-piano-hands.jpg', eyebrow: 'Studio estetik · Gading Serpong', title: 'Belajar di tempat yang bikin betah.', subtitle: 'Ruang kelas & studio recording standar pro. Datang buat les, pulang bawa vibe.', typing: false, }, ], images: [ 'assets/photos/hero-band.jpg', 'assets/photos/hero-drum-kid.jpg', 'assets/photos/hero-piano-hands.jpg', ], }, marquee: { items: [ 'Guitar', 'Piano', 'Drum', 'Bass', 'Vocal', 'Anak & Dewasa', 'Private · Group · In-Home', 'Pre-Beginner → Performance 3', '6 Skill Dimensions', '59 Stamps', 'Parent Dashboard', 'Studio Recording & Rehearsal', ], }, stats: { items: [ { value: '5', suffix: '', label: 'Instrumen' }, { value: '6', suffix: '', label: 'Level journey' }, { value: '59', suffix: '', label: 'Stamp dikoleksi' }, { value: '92', suffix: '%', label: 'Lanjut setelah bulan 3' }, ], }, instruments: { title: 'Lima instrumen. Satu tempat buat jatuh cinta sama musik.', intro: 'Mau mulai dari mana? Pilih instrumen kamu — atau anak kamu. Semua diajar dengan lagu beneran sejak minggu pertama.', items: [ { key: 'guitar', name: 'Guitar', desc: 'Dari open chord pertama sampai fingerstyle solo. Akustik, elektrik, klasik — pilih gitar kamu.', image: 'assets/photos/lesson-guitar.jpg', wa_text: 'Halo IMS! Aku mau coba kelas gitar.' }, { key: 'piano', name: 'Piano', desc: 'Koordinasi dua tangan, pengenalan chord & scale, teori dasar. Pop dan lainnya, sesuai karakter kamu.', image: 'assets/photos/lesson-piano.jpg', wa_text: 'Halo IMS! Aku mau coba kelas piano.' }, { key: 'drum', name: 'Drum', desc: 'Koordinasi tangan dan kaki, groove basic 4/4, drum fill pertama kamu. Rock dan Pop.', image: 'assets/photos/hero-drum-kid.jpg', wa_text: 'Halo IMS! Aku mau coba kelas drum.' }, { key: 'bass', name: 'Bass', desc: 'Feel, groove, dan fondasi dalam sebuah lagu. Petik dua jari yang stabil, lock sama drum.', image: 'assets/photos/lesson-bass.jpg', wa_text: 'Halo IMS! Aku mau coba kelas bass.' }, { key: 'vocal', name: 'Vocal', desc: 'Cari suara asli kamu. Napas, pitch, tone — plus pede pakainya. Dari matching pitch sampai lagu utuh.', image: 'assets/photos/lesson-vocal.jpg', wa_text: 'Halo IMS! Aku mau coba kelas vokal.' }, ], }, method: { title: 'Progress yang beneran keliatan — tanpa bikin kayak ujian.', intro: 'Sekolah lain biasanya cuma bilang "good job" terus selesai. Kita bikin perjalanan kamu kelihatan, dari chord pertama sampai naik panggung.', steps: [ { title: 'Coba dulu, gratis', desc: 'Session pertama gratis buat murid baru. Nggak perlu kartu kredit, nggak perlu komitmen. Dateng, main, rasain vibe-nya.' }, { title: 'Ketemu guru & level kamu', desc: 'Kita mulai dari posisi kamu sekarang — Pre-Beginner sampai Performance. Nggak ada yang buru-buru, nggak ada yang ketinggalan.' }, { title: 'Level + Radar + Badges', desc: 'Tiga lensa jujur: Level Journey (6 milestone), Skill Radar (6 dimensi di-score tiap session), dan Milestone Badges yang di-unlock kalau kamu beneran udah bisa.' }, { title: 'Naik panggung & masuk studio', desc: 'Di Performance 1, kamu dapet studio recording session beneran — bukan add-on berbayar. Take-nya kamu bawa pulang.' }, ], }, teachers: { title: 'Belajar bareng orangnya langsung.', intro: 'Di IMS, kamu nggak diajar sama "instruktur generasi ke-tiga" yang baru kerja minggu lalu. Kamu diajar sama orang yang bikin sekolahnya — kurikulum, filosofi, dan vibe-nya.', items: [ { name: 'Andre Putra', role: 'Co-Founder & Main Teacher', instrument: 'Guitar', bio: 'Andre udah main gitar sejak kelas 6 SD, mulai ngajar sejak SMA, dan akhirnya mendirikan INDIE Music School karena capek lihat banyak anak belajar musik tapi malah nggak enjoy. Filosofinya simple: musik seharusnya jadi tempat buat bahagia, berekspresi, dan berkembang — bukan bikin stress.', photo_url: '', }, ], }, gallery: { title: 'Intip dulu tempatnya.', images: [ { src: 'assets/photos/group-kids.jpg', caption: 'Kelas grup anak — seru, nggak nyeremin.' }, { src: 'assets/photos/recital-stage.jpg', caption: 'Recital & panggung — momen naik level.' }, { src: 'assets/photos/studio-gear.jpg', caption: 'Studio gear — dipakai murid & musisi luar.' }, { src: 'assets/photos/lesson-piano.jpg', caption: 'Ruang kelas piano yang hangat.' }, { src: 'assets/photos/hero-band.jpg', caption: 'Live room — rekam single yang siap rilis.' }, { src: 'assets/photos/lesson-guitar.jpg', caption: 'One-on-one, langsung sama founder-nya.' }, ], }, testimonials: { title: 'Kata mereka yang udah main bareng kita.', items: [ { quote: 'Anak gue dulu suka minta skip les piano. Sekarang malah minta berangkat lebih cepet biar bisa ngobrol dulu sama gurunya sebelum kelas mulai. Itu udah review-nya.', name: 'Rina P.', meta: 'Orang tua Kayla, 9 thn · Piano · 8 bulan di IMS' }, { quote: 'Umur 34 baru mulai belajar gitar dari nol total. Ternyata nggak seseram itu — Pre-Beginner emang didesain buat orang kayak aku. Sekarang udah bisa ngiringin lagu sendiri.', name: 'Danang S.', meta: 'Murid dewasa · Guitar · Private' }, { quote: 'Yang bikin beda itu progress-nya keliatan. Tiap minggu ada catatan, ada stamp, radar-nya nambah. Jadi tahu uang les nggak ke mana-mana.', name: 'Melisa T.', meta: 'Orang tua Raka, 11 thn · Drum' }, ], }, pricing: { title: 'Satu kurikulum, tiga cara belajar.', intro: 'Kurikulum sama, ritme beda. Semua harga per bulan (4 session). Sepanjang tahun kamu boleh kombinasi format.', plans: [ { name: 'Group', price: 'Rp 700rb', unit: '/ bulan · 4 session', features: ['Maks 4 murid per kelas', 'Lebih hemat, lebih seru', 'Skill Radar + Stamp card', 'Parent dashboard'], highlight: false }, { name: 'Private', price: 'Rp 1,1jt', unit: '/ bulan · 4 session', features: ['1-on-1 langsung sama guru', 'Jadwal fleksibel', 'Skill Radar + Milestone Badges', 'Studio session di Performance 1', 'Parent dashboard'], highlight: true }, { name: 'In-Home Private', price: 'Rp 1,5jt', unit: '/ bulan · 4 session', features: ['Guru datang ke rumah kamu', 'Greater Jakarta only', 'Semua benefit Private', 'Nggak perlu jalan macet'], highlight: false }, ], }, faq: { title: 'Pertanyaan yang sering masuk ke WA kita.', items: [ { q: 'Berapa lama sampai aku bisa main satu lagu utuh?', a: 'Biasanya minggu ke-3 udah bisa. Lagu beneran, yang orang kenal, yang "bisa direkam pakai HP". Belum Chopin sih, tapi udah musik.' }, { q: 'Kalau aku/anakku mau ganti instrumen, bisa?', a: 'Bebas ganti kapan aja. Stamp card sama Skill Radar yang umum (rhythm, ear, theory) tetep nyangkut. Yang spesifik per instrumen reset.' }, { q: 'Ada prep ujian musik (ABRSM, RSL) nggak?', a: 'Ada. Kasih tau pas daftar — kita slot-in ke kurikulum yang sesuai. Andre handle prep gitar; piano dikejar bareng kurikulum standar.' }, { q: 'Orang dewasa beneran bisa mulai dari nol?', a: 'Setengah murid dewasa kita mulai dari nol total. Pre-Beginner emang didesain buat itu. Nggak ada yang buru-buru.' }, { q: 'Policy cancel / reschedule gimana?', a: 'Reschedule maksimal 6 jam sebelum kelas — gratis. Lebih dari itu, kehitung. Semua status ke-track di dashboard.' }, { q: 'Studio bisa di-rent buat non-murid IMS?', a: 'Bisa. Booking by hour, dengan atau tanpa engineer. WA aja buat cek slot kosong. Murid IMS dapet prioritas + rate khusus.' }, ], }, cta: { title: 'Mampir dulu. Pulang udah senyum.', subtitle: 'Booking trial 90 detik. Kita match-in kamu ke guru, hold slot kamu, dan siapin instrumen yang udah ditune dari tadi.', button: { label: 'Coba kelas gratis via WhatsApp', url: waLink('Halo IMS! Aku mau booking kelas trial gratis.') }, }, contact: { address: 'Ruko Neo Arcade Blok A33, Gading Serpong, Tangerang, Banten 15334', phone: '+628998267999', wa: waLink('Halo IMS!'), instagram: 'https://www.instagram.com/indiemusic.id', maps_url: 'https://maps.google.com/?q=-6.243819059545993,106.62397551047178', hours: 'Buka tiap hari · 10.00 – 21.00', }, }; /* ───────────────────────────────────────────── Deep merge — CMS data over defaults. Objects merge recursively; arrays & scalars replace. ───────────────────────────────────────────── */ function isPlainObject(v) { return v && typeof v === 'object' && !Array.isArray(v); } function deepMerge(base, over) { if (!isPlainObject(base) || !isPlainObject(over)) { return over === undefined ? base : over; } const out = Object.assign({}, base); for (const k of Object.keys(over)) { if (isPlainObject(base[k]) && isPlainObject(over[k])) { out[k] = deepMerge(base[k], over[k]); } else if (over[k] !== undefined) { out[k] = over[k]; } } return out; } /* ───────────────────────────────────────────── Content hook — render defaults immediately, re-render when Supabase resolves. Any failure → defaults. ───────────────────────────────────────────── */ function useContent() { const [content, setContent] = useState(DEFAULT_CONTENT); useEffect(() => { let alive = true; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT); const url = SUPABASE_URL + '/rest/v1/' + CONTENT_TABLE + '?select=section,data'; fetch(url, { headers: { apikey: SUPABASE_ANON_KEY, Authorization: 'Bearer ' + SUPABASE_ANON_KEY, }, signal: controller.signal, }) .then(r => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))) .then(rows => { if (!alive || !Array.isArray(rows)) return; const merged = Object.assign({}, DEFAULT_CONTENT); for (const row of rows) { if (!row || !row.section) continue; const base = DEFAULT_CONTENT[row.section] || {}; merged[row.section] = deepMerge(base, row.data || {}); } setContent(merged); }) .catch(() => { /* silent → defaults stay */ }) .finally(() => clearTimeout(timer)); return () => { alive = false; clearTimeout(timer); controller.abort(); }; }, []); return content; } /* ───────────────────────────────────────────── Reveal-on-scroll — IntersectionObserver, reduced-motion safe. Returns a ref; adds .is-in when element enters viewport. ───────────────────────────────────────────── */ const FORCE_MOTION = typeof window !== 'undefined' && /[?&]motion=1/.test(window.location.search); const PREFERS_REDUCED = !FORCE_MOTION && typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; // ?motion=1 juga menonaktifkan blok CSS reduced-motion if (FORCE_MOTION && typeof document !== 'undefined') { document.documentElement.classList.add('force-motion'); } function useReveal(options) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; if (PREFERS_REDUCED || !('IntersectionObserver' in window)) { el.classList.add('is-in'); return; } const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { el.classList.add('is-in'); io.unobserve(el); } }); }, Object.assign({ threshold: 0.15, rootMargin: '0px 0px -8% 0px' }, options)); io.observe(el); return () => io.disconnect(); }, []); return ref; } function Reveal({ children, delay = 0, className = '', tag = 'div', style = {}, ...rest }) { const ref = useReveal(); const Tag = tag; return ( {children} ); } /* ───────────────────────────────────────────── Count-up — animates when scrolled into view. ───────────────────────────────────────────── */ function useInView(threshold = 0.4) { const ref = useRef(null); const [inView, setInView] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; if (!('IntersectionObserver' in window)) { setInView(true); return; } const io = new IntersectionObserver((entries) => { entries.forEach(e => { if (e.isIntersecting) { setInView(true); io.unobserve(el); } }); }, { threshold }); io.observe(el); return () => io.disconnect(); }, []); return [ref, inView]; } function useTypewriter(text, active, speed = 52) { const full = String(text || ''); const [n, setN] = useState(active && !PREFERS_REDUCED ? 0 : full.length); useEffect(() => { if (!active || PREFERS_REDUCED) { setN(full.length); return; } setN(0); const t0 = Date.now(); const t = setInterval(() => { const n = Math.min(full.length, Math.floor((Date.now() - t0) / speed)); setN(n); if (n >= full.length) clearInterval(t); }, 40); return () => clearInterval(t); }, [full, active]); return [full.slice(0, n), n < full.length]; } function CountUp({ value, suffix = '' }) { const target = parseFloat(String(value).replace(/[^0-9.]/g, '')); const decimals = String(value).includes('.') ? 1 : 0; const [ref, inView] = useInView(0.5); const [display, setDisplay] = useState(isNaN(target) ? value : (PREFERS_REDUCED ? value : '0')); useEffect(() => { if (!inView || isNaN(target)) { if (isNaN(target)) setDisplay(value); return; } if (PREFERS_REDUCED) { setDisplay(target.toFixed(decimals)); return; } let raf; const dur = 1400; let start; const tick = (ts) => { if (start === undefined) start = ts; const p = Math.min((ts - start) / dur, 1); const eased = 1 - Math.pow(1 - p, 3); setDisplay((target * eased).toFixed(decimals)); if (p < 1) raf = requestAnimationFrame(tick); else setDisplay(target.toFixed(decimals)); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [inView, target]); return {display}{suffix}; } /* ───────────────────────────────────────────── NavBar — transparent over hero → solid on scroll. ───────────────────────────────────────────── */ const NAV_LINKS = [ { href: '#programs', label: 'Program' }, { href: '#method', label: 'Metode' }, { href: '#teachers', label: 'Guru' }, { href: '#studio', label: 'Studio' }, { href: '#pricing', label: 'Harga' }, { href: '#faq', label: 'FAQ' }, ]; function NavBar({ content }) { const [scrolled, setScrolled] = useState(false); const [open, setOpen] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 24); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); return () => window.removeEventListener('scroll', onScroll); }, []); useEffect(() => { document.body.style.overflow = open ? 'hidden' : ''; return () => { document.body.style.overflow = ''; }; }, [open]); const cta = content.hero.cta_primary || {}; return (
setOpen(false)}> INDIE Music INDIE Music School & Studio
Member sign in {cta.label || 'Coba kelas gratis'}
setOpen(false)}> {cta.label || 'Coba kelas gratis'} Member sign in ↗
); } /* ───────────────────────────────────────────── Hero — Ken Burns slideshow OR video, dark overlay. ───────────────────────────────────────────── */ function Hero({ data }) { const legacyImages = (data.images && data.images.length) ? data.images : DEFAULT_CONTENT.hero.images; const slides = (data.slides && data.slides.length) ? data.slides : legacyImages.map((img, i) => ({ image: img, eyebrow: data.eyebrow, title: data.title, subtitle: data.subtitle, typing: i === 0, })); const [idx, setIdx] = useState(0); const hasVideo = !!(data.video_url && data.video_url.trim()); const cur = slides[idx % slides.length] || slides[0]; useEffect(() => { if (hasVideo || slides.length <= 1) return; const t = setInterval(() => setIdx(i => (i + 1) % slides.length), 9000); return () => clearInterval(t); }, [hasVideo, slides.length]); const p = data.cta_primary || {}; const sec = data.cta_secondary || {}; const [typed, isTyping] = useTypewriter(cur.title, !!cur.typing); return (
{hasVideo ? ( ) : ( slides.map((sl, i) => (
)) )}
{cur.eyebrow}

{cur.typing && !PREFERS_REDUCED ? ( <> {cur.title} {typed} ) : cur.title}

{cur.subtitle}

{p.label && {p.label}} {sec.label && {sec.label}}
{slides.length > 1 && !hasVideo && (
{slides.map((_, i) => (
)} Scroll
); } /* ───────────────────────────────────────────── Marquee — infinite CSS scroll. ───────────────────────────────────────────── */ function Marquee({ data }) { const items = data.items || []; const run = items.concat(items); return (
{run.map((t, i) => ( {t} ))}
); } /* ───────────────────────────────────────────── Stats band — count-up on scroll. ───────────────────────────────────────────── */ function Stats({ data }) { const items = data.items || []; return (
{items.map((s, i) => (
{s.label}
))}
); } /* ───────────────────────────────────────────── Instruments — editorial photo cards. ───────────────────────────────────────────── */ function Instruments({ data }) { const items = data.items || []; return (
Program · Pilih instrumen kamu

{data.title}

{data.intro}

{items.map((it, i) => (
{it.name}
0{i + 1}

{it.name}

{it.desc}

Coba kelas {it.name.toLowerCase()}
))}
); } /* ───────────────────────────────────────────── Method — numbered alternating steps. ───────────────────────────────────────────── */ function Method({ data }) { const steps = data.steps || []; return (
Metode IMS · A + B + C

{data.title}

{data.intro}

{steps.map((st, i) => (
{String(i + 1).padStart(2, '0')}

{st.title}

{st.desc}

))}
); } /* ───────────────────────────────────────────── Teachers — photo OR monogram cards. ───────────────────────────────────────────── */ function initialsOf(name) { return (name || '') .split(/\s+/) .filter(Boolean) .slice(0, 2) .map(w => w[0]) .join('') .toUpperCase(); } function Teachers({ data }) { const items = data.items || []; return (
Guru · Diajar langsung sama Co-Founder

{data.title}

{data.intro}

{items.map((t, i) => (
{t.photo_url ? {t.name} : {initialsOf(t.name)}} {t.instrument && {t.instrument}}

{t.name}

{t.role}

{t.bio}

))}
); } /* ───────────────────────────────────────────── Gallery — horizontal scroll-snap strip. ───────────────────────────────────────────── */ function Gallery({ data }) { const imgs = data.images || []; return (
Studio · Recording & Rehearsal

{data.title}

{imgs.map((im, i) => (
{im.caption {im.caption &&
{im.caption}
}
))}
); } /* ───────────────────────────────────────────── Testimonials — auto-advancing carousel + swipe. ───────────────────────────────────────────── */ function Testimonials({ data }) { const items = data.items || []; const [idx, setIdx] = useState(0); const touchX = useRef(null); const n = items.length; const go = useCallback((d) => setIdx(i => (i + d + n) % n), [n]); useEffect(() => { if (n <= 1 || PREFERS_REDUCED) return; const t = setInterval(() => setIdx(i => (i + 1) % n), 6000); return () => clearInterval(t); }, [n]); if (!n) return null; const onTouchStart = e => { touchX.current = e.touches[0].clientX; }; const onTouchEnd = e => { if (touchX.current === null) return; const dx = e.changedTouches[0].clientX - touchX.current; if (Math.abs(dx) > 50) go(dx < 0 ? 1 : -1); touchX.current = null; }; return (
Testimoni · Jawaban jujur

{data.title}

{items.map((t, i) => (
“{t.quote}”
{initialsOf(t.name)} {t.name} {t.meta}
))}
{n > 1 && (
{items.map((_, i) => (
)}
); } /* ───────────────────────────────────────────── Pricing — clean cards, one highlighted. ───────────────────────────────────────────── */ function Pricing({ data }) { const plans = data.plans || []; return (
Harga · Transparan

{data.title}

{data.intro}

{plans.map((pl, i) => ( {pl.highlight &&
Paling populer
}
{pl.name}
{pl.price}
{pl.unit}
    {(pl.features || []).map((f, j) => (
  • {f}
  • ))}
Pilih {pl.name}
))}

Session pertama gratis buat murid baru. Bayar setelah trial, lewat Xendit.

); } /* ───────────────────────────────────────────── FAQ — accessible accordion. ───────────────────────────────────────────── */ function FAQ({ data }) { const items = data.items || []; const [open, setOpen] = useState(0); return (
FAQ · Jawaban jujur

{data.title}

Selain ini, WA aja langsung. {WA_DISPLAY}.

Tanya via WhatsApp
{items.map((f, i) => { const isOpen = open === i; return (
{f.a}
); })}
); } /* ───────────────────────────────────────────── Final CTA — full-width dark. ───────────────────────────────────────────── */ function FinalCTA({ data }) { const btn = data.button || {}; return (
MAIN.
「 Kelas trial · Gratis buat murid baru 」

{data.title}

{data.subtitle}

{btn.label || 'Coba kelas gratis'}
); } /* ───────────────────────────────────────────── Footer + contact. ───────────────────────────────────────────── */ const SISTER_LOGOS = ['indiebrand', 'indiespace', 'indiekost', 'indiecafe']; function Footer({ contact }) { const c = contact || {}; const phoneDigits = (c.phone || '').replace(/[^0-9]/g, ''); return ( ); } function FooterCol({ title, items }) { return (
{title}
); } /* Floating WhatsApp button */ function WAFloat() { return ( ); } /* ───────────────────────────────────────────── App ───────────────────────────────────────────── */ function App() { const c = useContent(); return (