/* @jsx React.createElement */ /* ============================================================ INDIE Music — Home Private: kode BERSAMA les-di-rumah.jsx & jadi-guru.jsx (Q5 disetujui). React 18 UMD + Babel standalone, tanpa build. Dimuat SEBELUM file halaman; semua ekspor lewat window.HP (dibungkus IIFE agar tidak ada nama global yang bentrok dengan file halaman). MODE PRATINJAU (?demo=1): hanya bila URL memuat demo=1. Saat itu hp-demo.js dimuat dinamis, memasang penyadap fetch (data contoh, nol request ke dash.indiemusic.id), menandai halaman noindex, dan halaman menampilkan banner PRATINJAU. Tanpa demo=1, hp-demo.js tidak pernah dimuat dan perilaku = produksi. TAHAP 3 (hp5): peta gaya OpenFreeMap "liberty" di-restyle ke palet IMS + tempat penting (POI dari tile), zona guru ±1 km dengan badge bernomor, pin lokasi baru; komponen wizard (progres, WA admin, sheet, stepper). ============================================================ */ (function () { const { useState, useEffect, useRef, useCallback } = React; const HP_API = 'https://dash.indiemusic.id/api/public/hp'; const ASSET_VER = '20261002hp5'; const WA_NUMBER = '628998267999'; // nomor Admin IMS yang sama dengan site.jsx / shop.jsx const WA_DISPLAY = '+62 899-8267-999'; const waLink = (text) => 'https://wa.me/' + WA_NUMBER + (text ? '?text=' + encodeURIComponent(text) : ''); const rupiah = (n) => 'Rp' + Number(n || 0).toLocaleString('id-ID'); /* ── Mode pratinjau ── */ const DEMO = typeof location !== 'undefined' && new URLSearchParams(location.search).get('demo') === '1'; let demoReady = Promise.resolve(false); if (DEMO) { document.documentElement.classList.add('hp-demo'); let robots = document.querySelector('meta[name="robots"]'); if (!robots) { robots = document.createElement('meta'); robots.name = 'robots'; document.head.appendChild(robots); } robots.content = 'noindex'; demoReady = new Promise((resolve) => { const s = document.createElement('script'); s.src = 'hp-demo.js?v=' + ASSET_VER; s.onload = () => resolve(!!window.HP_DEMO_ACTIVE); s.onerror = () => resolve(false); document.head.appendChild(s); }); } // Titik pusat area pilihan manual — perkiraan pusat kawasan, BUKAN alamat (HP-PAGES-REPORT.md §3). const AREAS = [ { key: 'gading-serpong', group: 'Area pilot', label: 'Gading Serpong (Kelapa Dua)', short: 'Gading Serpong', lat: -6.2389, lng: 106.6286 }, { key: 'bsd', group: 'Area pilot', label: 'BSD City (Serpong)', short: 'BSD', lat: -6.302, lng: 106.652 }, { key: 'alam-sutera', group: 'Area pilot', label: 'Alam Sutera (Serpong Utara)', short: 'Alam Sutera', lat: -6.224, lng: 106.652 }, { key: 'karawaci', group: 'Sekitar area pilot', label: 'Lippo Karawaci', short: 'Karawaci', lat: -6.227, lng: 106.608 }, { key: 'cikokol', group: 'Sekitar area pilot', label: 'Tangerang Kota (Cikokol)', short: 'Cikokol', lat: -6.196, lng: 106.634 }, { key: 'bintaro', group: 'Sekitar area pilot', label: 'Bintaro Jaya (Pondok Aren)', short: 'Bintaro', lat: -6.278, lng: 106.718 }, { key: 'puri', group: 'Sekitar area pilot', label: 'Puri Indah / Kembangan (Jakarta Barat)', short: 'Puri', lat: -6.188, lng: 106.735 }, ]; const PILOT_AREAS = AREAS.filter((a) => a.group === 'Area pilot'); const PILOT_LABELS = PILOT_AREAS.map((a) => a.short); // [Guessing] Batas "masuk area pilot" = 10 km dari pusat area pilot terdekat (SPEC §9 hanya menyebut klaster // Gading Serpong – BSD – Alam Sutera tanpa angka). Dihitung di perangkat; lokasi tidak dikirim ke mana pun. const PILOT_KM = 10; const PHONE_RE = /^628\d{7,12}$/; const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/; function normPhone(raw) { const d = String(raw || '').replace(/[^0-9]/g, ''); if (!d) return ''; if (d[0] === '0') return '62' + d.slice(1); if (d.startsWith('62')) return d; return '62' + d; } const round3 = (x) => Math.round(x * 1000) / 1000; // ±100 m — cukup untuk pencarian, tidak presisi rumah const rad = (d) => (d * Math.PI) / 180; function haversineKm(a, b) { const dLat = rad(b.lat - a.lat); const dLng = rad(b.lng - a.lng); const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2; return 2 * 6371.0088 * Math.asin(Math.min(1, Math.sqrt(h))); } // Cek jangkauan pilot di perangkat: jarak ke pusat area pilot terdekat. function pilotCheck(loc, maxKm) { if (!loc) return null; let best = null; for (const a of PILOT_AREAS) { const km = haversineKm(loc, a); if (!best || km < best.km) best = { area: a, km }; } const km = Math.round(best.km * 10) / 10; const tier = best.km <= PILOT_KM ? 'in' : best.km <= (maxKm || 15) ? 'edge' : 'out'; return { area: best.area, km, tier }; } /* ── API: semua status diterjemahkan ke pesan ramah, tidak pernah error mentah ── */ async function hpApi(path, opts = {}) { if (DEMO) { const ok = await demoReady; // Pratinjau tanpa penyadap = jangan pernah jatuh ke API asli. if (!ok) return { ok: false, status: 0, body: {} }; } const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), opts.timeout || 15000); if (opts.signal) opts.signal.addEventListener('abort', () => ctl.abort()); try { const r = await fetch(HP_API + path, { method: opts.method || 'GET', headers: opts.body ? { 'Content-Type': 'application/json' } : undefined, body: opts.body ? JSON.stringify(opts.body) : undefined, signal: ctl.signal, credentials: 'omit', }); const body = await r.json().catch(() => ({})); return { ok: r.ok, status: r.status, body, retryAfter: Number(r.headers.get('Retry-After')) || null }; } catch (e) { return { ok: false, status: 0, body: {}, aborted: !!(opts.signal && opts.signal.aborted) }; } finally { clearTimeout(timer); } } // kind: 'request' (ortu) | 'apply' (guru, butuh maxMb) function friendlyError(res, kind = 'request', maxMb = 5) { const s = res.status; const msg = res.body && res.body.error; const guru = kind === 'apply'; if (s === 400) return msg || 'Ada isian yang belum sesuai. Cek lagi ya.'; if (s === 404) return guru ? 'Pendaftaran guru belum dibuka. Hubungi admin IMS via WhatsApp untuk info terbaru.' : 'Layanan ini belum dibuka. Hubungi admin IMS via WhatsApp untuk info terbaru.'; if (s === 413) { if (!guru) return 'Kiriman terlalu besar. Perkecil isian lalu kirim ulang.'; return (msg && /terlalu besar/i.test(msg) ? msg + '. ' : 'Kiriman terlalu besar. ') + 'Perkecil foto/berkas (maks ' + maxMb + ' MB per berkas) lalu kirim ulang.'; } if (s === 429) { const m = res.retryAfter ? Math.max(1, Math.ceil(res.retryAfter / 60)) : null; return 'Terlalu banyak percobaan dari jaringan kamu. Coba lagi ' + (m ? 'sekitar ' + m + ' menit lagi' : (guru ? 'nanti (paling lama 1 jam)' : 'beberapa saat lagi')) + ', atau hubungi admin via WhatsApp.'; } if (s === 403) return 'Permintaan ditolak server. Buka halaman ini langsung dari indiemusic.id lalu coba lagi.'; if (s === 0) return guru ? 'Koneksi ke server IMS gagal atau terlalu lama (unggahan besar butuh sinyal stabil). Cek internet lalu coba lagi.' : 'Koneksi ke server IMS gagal. Cek internet kamu lalu coba lagi — atau hubungi admin via WhatsApp.'; return guru ? 'Server IMS sedang bermasalah, lamaran kamu belum terkirim. Coba lagi beberapa saat lagi, atau hubungi admin via WhatsApp.' : 'Server IMS sedang bermasalah, data kamu belum terkirim. Coba lagi beberapa saat lagi, atau hubungi admin via WhatsApp.'; } // /config: 'loading' | 'open' | 'closed' (404 = saklar mati) | 'unavailable' (jaringan/5xx) function useHpConfig() { const [st, setSt] = useState({ state: 'loading', cfg: null }); useEffect(() => { let alive = true; hpApi('/config', { timeout: 9000 }).then((res) => { if (!alive) return; if (res.ok && res.body && res.body.ok && res.body.open !== false) setSt({ state: 'open', cfg: res.body }); else if (res.status === 404) setSt({ state: 'closed', cfg: null }); else setSt({ state: 'unavailable', cfg: null }); }); return () => { alive = false; }; }, []); const close = useCallback(() => setSt({ state: 'closed', cfg: null }), []); return [st, close]; } /* ── Gerak: semua animasi hanya bila prefers-reduced-motion TIDAK aktif ── */ const PREFERS_REDUCED = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; function Reveal({ children, delay = 0, className = '', tag = 'div', ...rest }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return undefined; if (PREFERS_REDUCED || !('IntersectionObserver' in window)) { el.classList.add('is-in'); return undefined; } const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { el.classList.add('is-in'); io.unobserve(el); } }), { threshold: 0.12, rootMargin: '0px 0px -6% 0px' }); io.observe(el); return () => io.disconnect(); }, []); const Tag = tag; return {children}; } // true selama elemen (atau salah satu id) terlihat di viewport function useInView(getEls, rootMargin = '0px') { const [inView, setInView] = useState(false); useEffect(() => { if (!('IntersectionObserver' in window)) return undefined; const els = getEls().filter(Boolean); if (!els.length) return undefined; const seen = new Set(); const io = new IntersectionObserver((es) => { es.forEach((e) => (e.isIntersecting ? seen.add(e.target) : seen.delete(e.target))); setInView(seen.size > 0); }, { rootMargin }); els.forEach((el) => io.observe(el)); return () => io.disconnect(); }); return inView; } const smoothScrollTo = (el) => el && el.scrollIntoView({ behavior: PREFERS_REDUCED ? 'auto' : 'smooth', block: 'start' }); /* ── Ikon garis per alat musik (dekoratif) — dipakai kartu alat, penanda peta, chip guru ── */ const INST_ICON = { gitar: , gitar_listrik: , piano: , vokal: , biola: , drum_pad: , }; const NOTE_ICON = ; function InstIcon({ k, size = 24, stroke = 1.6 }) { return ( {INST_ICON[k] || NOTE_ICON} ); } const INST_SVG = (k) => { // Versi string untuk penanda DOM MapLibre (di luar React). const p = { gitar: '', gitar_listrik: '', piano: '', vokal: '', biola: '', drum_pad: '', }[k] || ''; return ''; }; /* ── Peta — MapLibre GL 5.24.0 + OpenFreeMap "liberty" (punya POI), di-restyle ke palet IMS, lazy ── */ const MAPLIBRE_VER = '5.24.0'; const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'; const MAP_LOCALE = { 'CooperativeGesturesHandler.WindowsHelpText': 'Tahan Ctrl sambil scroll untuk zoom peta', 'CooperativeGesturesHandler.MacHelpText': 'Tahan ⌘ sambil scroll untuk zoom peta', 'CooperativeGesturesHandler.MobileHelpText': 'Pakai dua jari untuk menggeser peta', }; // Palet peta: krem hangat (kertas washi), air & taman lembut, aksen merah bata IMS. const MAPC = { cream: '#F6F1E7', water: '#C4DEE6', waterLine: '#A8CBD8', park: '#DCE7CB', wood: '#CFE0BC', resid: 'rgba(236, 226, 208, .5)', school: '#F2E6CF', hosp: '#F6E0DA', bldg: '#EAE1D1', bldgLine: '#DCD0BB', road: '#FFFFFF', roadCase: '#E4D8C4', major: '#FFF9EE', majorCase: '#DCC6A4', motor: '#F5CFAE', motorCase: '#D29A70', rail: '#CBBFAC', label: '#3F3A32', labelSoft: '#6E665A', halo: '#F8F4EC', brick: '#D03020', brickDeep: '#A52416', }; // Kategori "tempat penting" dari layer poi OpenMapTiles (data tile, BUKAN koordinat karangan). const POI_CAT = ['case', ['all', ['==', ['get', 'class'], 'railway'], ['match', ['get', 'subclass'], ['station', 'halt', 'subway'], true, false]], 'rail', ['all', ['==', ['get', 'class'], 'bus'], ['==', ['get', 'subclass'], 'bus_station']], 'rail', ['all', ['==', ['get', 'class'], 'shop'], ['match', ['get', 'subclass'], ['mall', 'department_store'], true, false]], 'mall', ['all', ['==', ['get', 'class'], 'hospital'], ['==', ['get', 'subclass'], 'hospital']], 'rs', ['==', ['get', 'class'], 'college'], 'kampus', ['==', ['get', 'class'], 'school'], 'sekolah', ['==', ['get', 'class'], 'park'], 'taman', ['==', ['get', 'class'], 'fuel'], 'spbu', '']; const POI_ICON = ['match', POI_CAT, 'rail', 'railway', 'mall', 'shop', 'rs', 'hospital', 'kampus', 'college', 'sekolah', 'school', 'taman', 'park', 'fuel']; const POI_COLOR = ['match', POI_CAT, 'rail', '#1F4E70', 'mall', '#8A3A10', 'rs', '#A52416', 'kampus', '#4E3F7E', 'sekolah', '#4E3F7E', 'taman', '#2F6224', '#5A554B']; // Per zoom: stasiun sejak zoom kota; mal/RS/kampus saat mendekat; sekolah/taman/SPBU hanya saat dekat sekali. const POI_LAYERS = [ // Ambang = isi tile OpenFreeMap (dicek 2 Okt 2026): tile z12–13 hanya memuat stasiun; mal/RS/kampus dst. mulai tile z14. { id: 'hp-poi-rail', minzoom: 11.5, cats: ['rail'], size: 12.5, font: 'Noto Sans Bold' }, { id: 'hp-poi-major', minzoom: 14, cats: ['mall', 'rs', 'kampus'], size: 12, font: 'Noto Sans Bold' }, { id: 'hp-poi-minor', minzoom: 15, cats: ['sekolah', 'taman', 'spbu'], size: 11.5, font: 'Noto Sans Regular' }, ]; const POI_IDS = POI_LAYERS.map((l) => l.id); function restyleBase(m) { const layers = m.getStyle().layers || []; const set = (id, k, v) => { try { m.setPaintProperty(id, k, v); } catch (e) { /* layer/properti tidak cocok — abaikan */ } }; const hide = (id) => { try { m.setLayoutProperty(id, 'visibility', 'none'); } catch (e) { /* abaikan */ } }; layers.forEach((l) => { const id = l.id; // Kebisingan: semua POI bawaan (diganti layer hp-poi-*), panah satu arah, gedung 3D, perisai jalan AS, bandara, lapangan. if (/^poi_|^road_one_way|^highway-shield-us|^road_shield_us|^building-3d|^aeroway_|^landuse_(pitch|track)$|^airport$/.test(id)) { hide(id); return; } if (id === 'background') set(id, 'background-color', MAPC.cream); else if (id === 'natural_earth') set(id, 'raster-opacity', 0.25); else if (id === 'water') set(id, 'fill-color', MAPC.water); else if (/^waterway/.test(id) && l.type === 'line') set(id, 'line-color', MAPC.waterLine); else if (id === 'park') { set(id, 'fill-color', MAPC.park); set(id, 'fill-opacity', 0.9); set(id, 'fill-outline-color', 'rgba(0,0,0,0)'); } else if (id === 'park_outline') set(id, 'line-opacity', 0); else if (id === 'landcover_wood') set(id, 'fill-color', MAPC.wood); else if (id === 'landcover_grass') { set(id, 'fill-color', MAPC.park); set(id, 'fill-opacity', 0.6); } else if (id === 'landuse_residential') set(id, 'fill-color', MAPC.resid); else if (id === 'landuse_school') set(id, 'fill-color', MAPC.school); else if (id === 'landuse_hospital') set(id, 'fill-color', MAPC.hosp); else if (id === 'building') { set(id, 'fill-color', MAPC.bldg); set(id, 'fill-outline-color', MAPC.bldgLine); } else if (l.type === 'line' && l['source-layer'] === 'transportation') { const casing = /casing/.test(id); if (/rail/.test(id)) set(id, 'line-color', MAPC.rail); else if (/motorway/.test(id)) set(id, 'line-color', casing ? MAPC.motorCase : MAPC.motor); else if (/trunk_primary|secondary_tertiary/.test(id)) set(id, 'line-color', casing ? MAPC.majorCase : MAPC.major); else if (!/path|pedestrian/.test(id)) set(id, 'line-color', casing ? MAPC.roadCase : MAPC.road); } else if (l.type === 'line' && /^boundary/.test(id)) set(id, 'line-opacity', 0.35); else if (l.type === 'symbol' && l.layout && l.layout['text-field']) { set(id, 'text-color', /^water|waterway/.test(id) ? '#3E6E80' : /^label_other|highway-name/.test(id) ? MAPC.labelSoft : MAPC.label); set(id, 'text-halo-color', MAPC.halo); set(id, 'text-halo-width', 1.4); } }); // Nama kelurahan/perumahan kecil baru muncul saat mendekat (mengurangi label berlebihan). try { m.setLayerZoomRange('label_other', 13, 24); } catch (e) { /* abaikan */ } try { m.setLayoutProperty('label_village', 'icon-image', ''); } catch (e) { /* abaikan */ } try { m.setLayerZoomRange('highway-name-minor', 16, 24); } catch (e) { /* abaikan */ } } function addPoiLayers(m, visible) { POI_LAYERS.forEach((p) => { if (m.getLayer(p.id)) return; m.addLayer({ id: p.id, type: 'symbol', source: 'openmaptiles', 'source-layer': 'poi', minzoom: p.minzoom, filter: ['all', ['has', 'name'], ['match', POI_CAT, p.cats, true, false]], layout: { visibility: visible ? 'visible' : 'none', 'symbol-sort-key': ['coalesce', ['get', 'rank'], 50], 'icon-image': POI_ICON, 'icon-size': 1, 'icon-padding': 2, 'text-field': ['coalesce', ['get', 'name:latin'], ['get', 'name']], 'text-font': [p.font], 'text-size': p.size, 'text-anchor': 'top', 'text-offset': [0, 0.85], 'text-max-width': 8, 'text-optional': true, 'text-padding': 3, }, paint: { 'text-color': POI_COLOR, 'text-halo-color': MAPC.halo, 'text-halo-width': 1.6, 'icon-opacity': 0.92 }, }); }); } let mapLibPromise = null; function loadMapLib() { if (window.maplibregl) return Promise.resolve(window.maplibregl); if (!mapLibPromise) { mapLibPromise = new Promise((resolve, reject) => { const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = 'https://unpkg.com/maplibre-gl@' + MAPLIBRE_VER + '/dist/maplibre-gl.css'; document.head.appendChild(css); const s = document.createElement('script'); s.src = 'https://unpkg.com/maplibre-gl@' + MAPLIBRE_VER + '/dist/maplibre-gl.js'; s.crossOrigin = 'anonymous'; s.onload = () => (window.maplibregl ? resolve(window.maplibregl) : reject(new Error('maplibre'))); s.onerror = () => { mapLibPromise = null; reject(new Error('maplibre')); }; document.head.appendChild(s); }); } return mapLibPromise; } const kmLat = (km) => km / 111.32; const kmLng = (km, lat) => km / (111.32 * Math.cos(rad(lat))); function circleRing(lat, lng, km, n = 72) { const pts = []; for (let i = 0; i <= n; i++) { const a = (i / n) * 2 * Math.PI; pts.push([lng + kmLng(km, lat) * Math.cos(a), lat + kmLat(km) * Math.sin(a)]); } return pts; } function circleFeature(lat, lng, km, props, n = 72) { return { type: 'Feature', properties: props || {}, geometry: { type: 'Polygon', coordinates: [circleRing(lat, lng, km, n)] } }; } // Area pilot = selubung cembung (convex hull) lingkaran PILOT_KM di sekitar tiga pusat area → satu bentuk halus. function pilotHull() { const pts = []; PILOT_AREAS.forEach((a) => circleRing(a.lat, a.lng, PILOT_KM, 64).forEach((p) => pts.push(p))); pts.sort((p, q) => p[0] - q[0] || p[1] - q[1]); const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); const lower = []; const upper = []; pts.forEach((p) => { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop(); lower.push(p); }); for (let i = pts.length - 1; i >= 0; i--) { const p = pts[i]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop(); upper.push(p); } const hull = lower.slice(0, -1).concat(upper.slice(0, -1)); hull.push(hull[0]); return hull; } const fc = (features) => ({ type: 'FeatureCollection', features }); const cellKey = (area) => (area ? area.lat + ',' + area.lng : ''); const PILOT_LABEL = 'AREA PILOT · GADING SERPONG – BSD – ALAM SUTERA'; // Zona guru: lingkaran lembut ±0,7 km yang menutupi sel 1 km (pusat sel API, BUKAN titik guru). const ZONE_KM = 0.72; const FIT_KM = 6; // bingkai hasil fokus ke guru ≤6 km → zoom lebih dekat, patokan (stasiun ≥z12, mal/RS ≥z14) lebih sering terlihat function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; } const PIN_HTML = ''; /* Props: pin {lat,lng} titik pengunjung (di perangkatnya sendiri); pinLabel teks label pin markers [{key,lat,lng,nums,inst,label}] sel ±1 km dari API (BUKAN titik guru); nums = nomor kartu di daftar hlKey penanda/zona yang disorot (sinkron dengan daftar) onMarker(key) penanda diketuk; onMarkerHover(key|null) onPick({lat,lng}) peta diketuk (pilih lokasi) — hanya bila diberikan onBlank() peta diketuk di area kosong (mis. tutup kartu mini) onLocate() tombol "Lokasi saya" di peta (opsional); locating = sedang mencari radiusKm lingkaran radius di sekitar pin (radius mengajar guru) pilotZone tampilkan area pilot berlabel + redupkan luar area fitKey berubah -> fitBounds halus ke pin + penanda legend [{sw,text}] ≤3 item; children = lapisan di atas peta (kartu mini) */ function HpMap({ pin, pinLabel, markers, hlKey, onMarker, onMarkerHover, onPick, onBlank, onLocate, locating, radiusKm, pilotZone, fitKey, label, describedBy, zoom = 11.5, legend, children, className }) { const wrap = useRef(null); const box = useRef(null); const map = useRef(null); const pinMk = useRef(null); const mks = useRef(new Map()); const cb = useRef({}); cb.current = { onPick, onMarker, onMarkerHover, onBlank }; const [near, setNear] = useState(false); const [ready, setReady] = useState(false); const [err, setErr] = useState(null); const [poiOn, setPoiOn] = useState(true); // Lazy: pustaka peta baru dimuat saat kotak peta mendekati layar. useEffect(() => { const w = wrap.current; if (!w || !('IntersectionObserver' in window)) { setNear(true); return undefined; } const io = new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) { setNear(true); io.disconnect(); } }, { rootMargin: '300px 0px' }); io.observe(w); return () => io.disconnect(); }, []); useEffect(() => { if (!near) return undefined; let dead = false; let ro = null; loadMapLib().then((lib) => { if (dead || !box.current) return; const c = pin || AREAS[0]; const m = new lib.Map({ container: box.current, style: MAP_STYLE, center: [c.lng, c.lat], zoom, attributionControl: { compact: true }, cooperativeGestures: true, locale: MAP_LOCALE, dragRotate: false, pitchWithRotate: false, touchPitch: false, maxZoom: 17.5, }); m.touchZoomRotate.disableRotation(); m.once('style.load', () => { restyleBase(m); const first = (m.getStyle().layers.find((l) => l.type === 'symbol') || {}).id; ['mask', 'pilot', 'radius', 'zones'].forEach((id) => m.addSource(id, { type: 'geojson', data: fc([]) })); m.addLayer({ id: 'mask-fill', type: 'fill', source: 'mask', paint: { 'fill-color': '#EAE2D3', 'fill-opacity': 0.5 } }, first); m.addLayer({ id: 'pilot-fill', type: 'fill', source: 'pilot', paint: { 'fill-color': MAPC.brick, 'fill-opacity': 0.035 } }, first); m.addLayer({ id: 'pilot-line', type: 'line', source: 'pilot', layout: { 'line-join': 'round' }, paint: { 'line-color': MAPC.brickDeep, 'line-width': 1.6, 'line-opacity': 0.55, 'line-dasharray': [2, 2] } }, first); m.addLayer({ id: 'radius-fill', type: 'fill', source: 'radius', paint: { 'fill-color': MAPC.brick, 'fill-opacity': 0.09 } }, first); m.addLayer({ id: 'radius-line', type: 'line', source: 'radius', paint: { 'line-color': MAPC.brick, 'line-width': 2, 'line-dasharray': [2, 1.5] } }, first); m.addLayer({ id: 'zones-outer', type: 'fill', source: 'zones', filter: ['==', ['get', 'ring'], 'outer'], paint: { 'fill-color': MAPC.brick, 'fill-opacity': 0.13 } }, first); m.addLayer({ id: 'zones-inner', type: 'fill', source: 'zones', filter: ['==', ['get', 'ring'], 'inner'], paint: { 'fill-color': MAPC.brick, 'fill-opacity': 0.13 } }, first); m.addLayer({ id: 'zones-line', type: 'line', source: 'zones', filter: ['==', ['get', 'ring'], 'outer'], paint: { 'line-color': MAPC.brickDeep, 'line-width': 1.2, 'line-opacity': 0.5, 'line-dasharray': [1.5, 1.5] } }, first); m.addLayer({ id: 'zones-hl', type: 'fill', source: 'zones', filter: ['==', ['get', 'key'], '__none__'], paint: { 'fill-color': MAPC.brick, 'fill-opacity': 0.2 } }, first); m.addLayer({ id: 'zones-hl-line', type: 'line', source: 'zones', filter: ['==', ['get', 'key'], '__none__'], paint: { 'line-color': MAPC.brickDeep, 'line-width': 2.4 } }, first); addPoiLayers(m, poiOn); // Atribusi tetap ada (tombol ⓘ), tapi dilipat agar tidak menumpuk dengan toggle tempat penting. // MapLibre membuka ulang saat data sumber pertama masuk -> lipat setelah idle pertama juga. const fold = () => { const a = box.current && box.current.querySelector('.maplibregl-ctrl-attrib'); if (a) { a.classList.remove('maplibregl-compact-show'); a.removeAttribute('open'); } }; fold(); m.once('idle', fold); // Label area pilot di sepanjang garisnya (di atas POI agar tidak tertutup). m.addLayer({ id: 'pilot-label', type: 'symbol', source: 'pilot', layout: { 'symbol-placement': 'line', 'symbol-spacing': 520, 'text-field': PILOT_LABEL, 'text-font': ['Noto Sans Bold'], 'text-size': 11.5, 'text-letter-spacing': 0.08, 'text-keep-upright': true, 'text-offset': [0, -0.9], }, paint: { 'text-color': MAPC.brickDeep, 'text-halo-color': MAPC.halo, 'text-halo-width': 2 } }); if (!dead) setReady(true); }); m.on('click', (e) => { if (cb.current.onPick) { cb.current.onPick({ lat: round3(e.lngLat.lat), lng: round3(e.lngLat.lng) }); return; } if (cb.current.onBlank) cb.current.onBlank(); }); map.current = { m, lib }; box.current.hpMap = m; // pegangan untuk uji otomatis if ('ResizeObserver' in window) { ro = new ResizeObserver(() => m.resize()); ro.observe(box.current); } }).catch(() => { if (!dead) setErr('Peta tidak bisa dimuat. Semua fitur tetap jalan lewat daftar & pilihan area.'); }); return () => { dead = true; if (ro) ro.disconnect(); mks.current.forEach((x) => x.mk.remove()); mks.current.clear(); if (map.current) map.current.m.remove(); map.current = null; pinMk.current = null; }; }, [near]); const src = (id) => map.current && map.current.m.getSource(id); // Pin lokasi (DOM, bukan pin hitam bawaan). useEffect(() => { const x = map.current; if (!x || !ready) return; if (pin) { if (!pinMk.current) { const e = el('div', 'hp-pin', PIN_HTML); pinMk.current = new x.lib.Marker({ element: e, anchor: 'bottom' }).setLngLat([pin.lng, pin.lat]).addTo(x.m); } else pinMk.current.setLngLat([pin.lng, pin.lat]); const lb = pinMk.current.getElement().querySelector('.hp-pin__lbl'); if (lb) lb.textContent = pinLabel || 'Lokasi kamu'; } else if (pinMk.current) { pinMk.current.remove(); pinMk.current = null; } src('radius').setData(fc(pin && radiusKm > 0 ? [circleFeature(pin.lat, pin.lng, Number(radiusKm))] : [])); }, [pin && pin.lat, pin && pin.lng, radiusKm, pinLabel, ready]); useEffect(() => { if (!map.current || !ready) return; if (pilotZone) { const hull = pilotHull(); src('pilot').setData(fc([{ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [hull] } }])); const world = [[-180, -85], [180, -85], [180, 85], [-180, 85], [-180, -85]]; src('mask').setData(fc([{ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [world, hull.slice().reverse()] } }])); } else { src('pilot').setData(fc([])); src('mask').setData(fc([])); } }, [pilotZone, ready]); // Zona + badge bernomor (DOM) per sel. const mkSig = (markers || []).map((k) => k.key + ':' + (k.nums || []).join('.') + ':' + (k.inst || '')).join('|'); useEffect(() => { const x = map.current; if (!x || !ready) return; const list = markers || []; const feats = []; list.forEach((k) => { feats.push(circleFeature(k.lat, k.lng, ZONE_KM, { key: k.key, ring: 'outer' }, 48)); feats.push(circleFeature(k.lat, k.lng, ZONE_KM * 0.55, { key: k.key, ring: 'inner' }, 40)); }); src('zones').setData(fc(feats)); const keep = new Set(list.map((k) => k.key)); mks.current.forEach((v, key) => { if (!keep.has(key)) { v.mk.remove(); mks.current.delete(key); } }); list.forEach((k) => { const nums = k.nums || []; const html = '' + (nums[0] || '') + '' + (nums.length > 1 ? '+' + (nums.length - 1) + '' : '') + '' + INST_SVG(k.inst) + ''; let v = mks.current.get(k.key); if (!v) { const b = el('button', 'hp-mk', html); b.type = 'button'; b.dataset.key = k.key; b.addEventListener('click', (e) => { e.stopPropagation(); if (cb.current.onMarker) cb.current.onMarker(k.key); }); b.addEventListener('mouseenter', () => cb.current.onMarkerHover && cb.current.onMarkerHover(k.key)); b.addEventListener('mouseleave', () => cb.current.onMarkerHover && cb.current.onMarkerHover(null)); b.addEventListener('focus', () => cb.current.onMarkerHover && cb.current.onMarkerHover(k.key)); v = { mk: new x.lib.Marker({ element: b, anchor: 'center' }).setLngLat([k.lng, k.lat]).addTo(x.m) }; mks.current.set(k.key, v); } else v.mk.getElement().innerHTML = html; v.mk.getElement().setAttribute('aria-label', k.label || ('Area guru nomor ' + nums.join(', '))); }); }, [mkSig, ready]); useEffect(() => { const x = map.current; if (!x || !ready) return; const f = ['==', ['get', 'key'], hlKey || '__none__']; x.m.setFilter('zones-hl', f); x.m.setFilter('zones-hl-line', ['all', ['==', ['get', 'ring'], 'outer'], f]); mks.current.forEach((v, key) => v.mk.getElement().classList.toggle('is-hl', key === hlKey)); }, [hlKey, ready, mkSig]); useEffect(() => { const x = map.current; if (!x || !ready) return; POI_IDS.forEach((id) => { if (x.m.getLayer(id)) x.m.setLayoutProperty(id, 'visibility', poiOn ? 'visible' : 'none'); }); }, [poiOn, ready]); // Bingkai halus: pin + semua zona; tanpa penanda -> pusatkan pin (+ radius bila ada). useEffect(() => { const x = map.current; if (!x || !ready || !pin) return; const dur = PREFERS_REDUCED ? 0 : 800; const pts = [[pin.lng, pin.lat]]; // Bingkai fokus ke guru ≤ FIT_KM (atau satu terdekat); yang lebih jauh tetap ada di daftar & bisa di-zoom-out. const all = (markers || []).map((k) => ({ k, km: haversineKm(pin, k) })).sort((a, b) => a.km - b.km); const focus = all.filter((x2) => x2.km <= FIT_KM); (focus.length ? focus : all.slice(0, 1)).forEach(({ k }) => { pts.push([k.lng - kmLng(ZONE_KM, k.lat), k.lat - kmLat(ZONE_KM)], [k.lng + kmLng(ZONE_KM, k.lat), k.lat + kmLat(ZONE_KM)]); }); if (radiusKm > 0) pts.push([pin.lng - kmLng(radiusKm, pin.lat), pin.lat - kmLat(radiusKm)], [pin.lng + kmLng(radiusKm, pin.lat), pin.lat + kmLat(radiusKm)]); const w = box.current ? box.current.clientWidth : 600; const pad = w < 480 ? { top: 64, bottom: 64, left: 34, right: 64 } : { top: 66, bottom: 66, left: 56, right: 76 }; if (pts.length > 1) { const b = new x.lib.LngLatBounds(pts[0], pts[0]); pts.forEach((p) => b.extend(p)); x.m.fitBounds(b, { padding: pad, maxZoom: 14.2, duration: dur, essential: true }); } else { // Hasil kosong (markers = []) → zoom kawasan; pilih titik / radius → zoom dekat agar tempat penting terlihat. x.m.easeTo({ center: [pin.lng, pin.lat], zoom: markers ? 12.5 : Math.max(x.m.getZoom(), 14), duration: dur, essential: true }); } }, [fitKey, ready]); const zoomBy = (d) => { const x = map.current; if (x) x.m.easeTo({ zoom: x.m.getZoom() + d, duration: PREFERS_REDUCED ? 0 : 250 }); }; return (
{!ready && !err &&
Memuat peta…
} {err &&
{err}
} {ready && (
{onLocate && ( )}
)} {children}
{legend && legend.length > 0 && (
    {legend.map((l) =>
  • {l.text}
  • )}
)}
); } // Teks area untuk pesan WA / ringkasan — TIDAK pernah memuat koordinat. function areaText(loc) { if (!loc) return ''; if (loc.source === 'area' && loc.label) return loc.label; let best = null; AREAS.forEach((a) => { const km = haversineKm(loc, a); if (!best || km < best.km) best = { a, km }; }); return best ? 'sekitar ' + best.a.label : ''; } // Geolokasi perangkat — dibulatkan ±100 m di perangkat. function useGps(onLoc) { const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const run = () => { setErr(null); if (!navigator.geolocation) { setErr('Browser kamu tidak mendukung lokasi. Pilih area dari daftar.'); return; } setBusy(true); navigator.geolocation.getCurrentPosition( (p) => { setBusy(false); onLoc({ lat: round3(p.coords.latitude), lng: round3(p.coords.longitude), source: 'gps', label: 'Lokasi perangkat kamu' }); }, () => { setBusy(false); setErr('Izin lokasi ditolak atau tidak tersedia. Pilih area dari daftar, atau ketuk peta.'); }, { enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }, ); }; return { busy, err, run }; } /* ── Pemilih lokasi: "Pakai lokasi saya" ATAU pilih area (peta bisa diketuk terpisah) ── */ function LocationPicker({ loc, setLoc, idPrefix, legend = 'Lokasi', areaText = 'Atau cari area', statusPrefix = 'Mencari dari', emptyText, privacy, invalid, errId, onReset }) { const gps = useGps(setLoc); const gpsBusy = gps.busy; const gpsErr = gps.err; const runGps = gps.run; const onArea = (e) => { const a = AREAS.find((x) => x.key === e.target.value); if (a) setLoc({ lat: a.lat, lng: a.lng, source: 'area', key: a.key, label: a.label }); }; const groups = [...new Set(AREAS.map((a) => a.group))]; return (
{legend}
{gpsErr &&
{gpsErr}
}
{loc ? (<> {statusPrefix}: {loc.label}{loc.source === 'area' ? ' (titik tengah area)' : ''} {onReset && }) : {emptyText || 'Belum ada lokasi — pakai lokasi perangkat, pilih area, atau ketuk peta.'}}
{privacy &&

{privacy}

}
); } /* ════════ Komponen WIZARD bersama (tahap 3) ════════ */ // Progres "Langkah X dari N" + bar; diumumkan pembaca layar saat langkah berubah. function WizardProgress({ step, total, title, id }) { return (
Langkah {step} dari {total}: {title} {Array.from({ length: total }, (_, i) => )}
); } // Jalan keluar ke WhatsApp admin — teks terisi dari data yang SUDAH diisi (tanpa koordinat). function WaHelp({ text, className = '', lead = 'Lebih gampang lewat WhatsApp?', cta = 'Chat admin' }) { return (

{lead} {cta}

); } // Lembar dialog (layar penuh di HP, panel tengah di desktop). Tinggi mengikuti visualViewport // agar tombol Lanjut tidak tertutup keyboard. Fokus terkunci di dalam; Esc menutup; fokus kembali ke pemicu. function Sheet({ open, onClose, labelledBy, children }) { const ref = useRef(null); const back = useRef(null); const closeRef = useRef(onClose); closeRef.current = onClose; useEffect(() => { if (!open) return undefined; back.current = document.activeElement; document.documentElement.classList.add('hp-noscroll'); const vv = window.visualViewport; const fit = () => { const p = ref.current; if (!p || !vv) return; p.style.setProperty('--vvh', Math.round(vv.height) + 'px'); p.style.setProperty('--vvt', Math.round(vv.offsetTop) + 'px'); }; fit(); if (vv) { vv.addEventListener('resize', fit); vv.addEventListener('scroll', fit); } const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); closeRef.current(); return; } if (e.key !== 'Tab' || !ref.current) return; const f = [...ref.current.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]):not([tabindex="-1"]),select,textarea,[tabindex="0"]')] .filter((x) => x.offsetParent !== null); if (!f.length) return; if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); } }; document.addEventListener('keydown', onKey); return () => { document.documentElement.classList.remove('hp-noscroll'); if (vv) { vv.removeEventListener('resize', fit); vv.removeEventListener('scroll', fit); } document.removeEventListener('keydown', onKey); if (back.current && back.current.focus) back.current.focus({ preventScroll: true }); }; }, [open]); if (!open) return null; return ReactDOM.createPortal(
{children}
, document.body, ); } // Selama wizard terlihat: → di HP tombol WA melayang disembunyikan // (footer wizard sudah punya tautan WA sendiri, tidak saling menutupi). function useWizOn(ref) { useEffect(() => { const w = ref.current; if (!w || !('IntersectionObserver' in window)) return undefined; const io = new IntersectionObserver((es) => document.documentElement.classList.toggle('hp-wiz-on', es[0].isIntersecting), { rootMargin: '-25% 0px -25% 0px' }); io.observe(w); return () => { io.disconnect(); document.documentElement.classList.remove('hp-wiz-on'); }; }, []); } function Segmented({ value, options, onChange, label, className = '' }) { return (
{options.map(([v, l]) => ( ))}
); } // Angka dengan tombol − / + (≥44 px). Kosong diperbolehkan bila optional. function Stepper({ id, value, onChange, min, max, start = 8, label, describedBy, invalid, placeholder }) { const n = value === '' ? null : Number(value); const step = (d) => { const base = n === null || Number.isNaN(n) ? start : n + d; onChange(String(Math.min(max, Math.max(min, base)))); }; return (
onChange(e.target.value.replace(/[^0-9]/g, '').slice(0, 2))} aria-invalid={invalid ? true : undefined} aria-describedby={describedBy} />
); } /* Pilih lokasi versi wizard: tombol besar GPS, kotak cari area (menyaring chip), atau ketuk peta. */ function AreaChooser({ loc, setLoc, idPrefix, gpsLabel = 'Pakai lokasi saya', invalid, errId, mapSlot }) { const gps = useGps(setLoc); const [qs, setQs] = useState(''); const norm = (s) => s.toLowerCase().replace(/[^a-z0-9 ]/g, ' '); const list = AREAS.filter((a) => !qs.trim() || norm(a.label + ' ' + a.short).includes(norm(qs).trim())); const groups = [...new Set(list.map((a) => a.group))]; return (
{gps.err &&
{gps.err}
}
atau pilih area
setQs(e.target.value)} autoComplete="off" enterKeyHint="search" /> {groups.map((g) => (
{g}
{list.filter((a) => a.group === g).map((a) => ( ))}
))} {!list.length &&

Area itu belum ada di daftar. Pakai lokasi kamu, ketuk peta, atau chat admin.

} {mapSlot}
); } /* ── Banner & tanda pratinjau ── */ function DemoBanner() { if (!DEMO) return null; return
PRATINJAU — data contoh, bukan guru/harga asli. Tidak ada yang terkirim.
; } const DemoTag = () => (DEMO ? contoh : null); /* ── Nav + footer — kelas dari styles.css (pola shop.jsx) ── */ function HpNav({ links, sub, ctaHref, ctaLabel }) { 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' : ''; if (!open) return undefined; const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; window.addEventListener('keydown', onKey); return () => { document.body.style.overflow = ''; window.removeEventListener('keydown', onKey); }; }, [open]); return (
INDIE Music — beranda INDIE Music {sub}
{ctaLabel}
); } function HpFooter({ blurb, schoolLinks }) { return ( ); } const WA_PATH = 'M16 3C9 3 3.5 8.5 3.5 15.5c0 2.4.7 4.7 1.9 6.7L3 29l7-1.8c1.9 1 4 1.6 6 1.6 7 0 12.5-5.5 12.5-12.5S23 3 16 3zm0 22.7c-1.8 0-3.6-.5-5.1-1.4l-.4-.2-4.2 1.1 1.1-4.1-.2-.4a10 10 0 01-1.6-5.5C5.4 9.7 10.2 5 16 5s10.6 4.7 10.6 10.5S21.8 25.7 16 25.7zm5.8-7.9c-.3-.2-1.9-.9-2.2-1s-.5-.2-.7.2-.8 1-1 1.2-.4.3-.7.1a8.7 8.7 0 01-2.6-1.6 9.6 9.6 0 01-1.8-2.2c-.2-.3 0-.5.1-.7l.5-.6c.2-.2.2-.4.3-.6s0-.4 0-.6l-1-2.4c-.3-.6-.5-.5-.7-.5h-.6c-.2 0-.6.1-.9.4a3.6 3.6 0 00-1.1 2.7c0 1.6 1.2 3.2 1.3 3.4s2.3 3.6 5.6 5c.8.3 1.4.5 1.9.7.8.3 1.5.2 2 .1.6-.1 1.9-.8 2.2-1.5s.3-1.4.2-1.5-.3-.2-.6-.4z'; const WaIcon = ({ size = 26 }) => ; function WAFloat({ text }) { return ; } /* Blok pengganti pencarian/formulir saat layanan belum dibuka (404) atau server tak terjangkau. */ function ComingSoon({ state, waText, eyebrow, soonText, downText }) { const down = state === 'unavailable'; return (
SOON.
{down ? 'Sebentar ya' : eyebrow}

{down ? 'Formulir belum bisa dimuat.' : 'Segera hadir di area pilot kami.'}

{down ? downText : soonText}

{PILOT_LABELS.map((a) => {a})}
Chat admin IMS via WhatsApp
); } function Faq({ eyebrow, title, intro, items }) { const [openIdx, setOpenIdx] = useState(0); return (
{eyebrow}

{title}

{intro} {WA_DISPLAY}.

{items.map((f, i) => { const isOpen = openIdx === i; return (
); })}
); } /* ── CTA lengket di mobile: muncul setelah hero, sembunyi saat bagian tujuan terlihat atau keyboard terbuka ── */ function StickyCta({ href, label, hideIds }) { const [past, setPast] = useState(false); const [typing, setTyping] = useState(false); const atTarget = useInView(() => hideIds.map((id) => document.getElementById(id))); useEffect(() => { const onScroll = () => setPast(window.scrollY > window.innerHeight * 0.7); onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); const isField = (el) => el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) && el.type !== 'checkbox' && el.type !== 'radio' && el.type !== 'range' && el.type !== 'file'; const onIn = (e) => setTyping(isField(e.target)); const onOut = () => setTyping(false); document.addEventListener('focusin', onIn); document.addEventListener('focusout', onOut); return () => { window.removeEventListener('scroll', onScroll); document.removeEventListener('focusin', onIn); document.removeEventListener('focusout', onOut); }; }, []); const show = past && !atTarget && !typing; return ( ); } /* ── Layar sukses setelah kirim: animasi ringan (centang tergambar), ringkasan, langkah berikut, WA ── */ function SuccessScreen({ title, message, summary, steps, waText, note }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.focus(); }, []); return (

{title}

{message}

Ringkasan yang kamu kirim
{summary.filter((r) => r[1]).map(([k, v], i) => (
{k}
{v}
))}
Langkah berikutnya
    {steps.map((s, i) =>
  1. {i + 1}{s}
  2. )}
{note &&

{note}

}
); } window.HP = { HP_API, ASSET_VER, WA_NUMBER, WA_DISPLAY, waLink, rupiah, DEMO, AREAS, PILOT_AREAS, PILOT_LABELS, PILOT_KM, PHONE_RE, HHMM, normPhone, round3, haversineKm, pilotCheck, hpApi, friendlyError, useHpConfig, PREFERS_REDUCED, Reveal, useInView, smoothScrollTo, loadMapLib, HpMap, cellKey, LocationPicker, DemoBanner, DemoTag, HpNav, HpFooter, WAFloat, WaIcon, ComingSoon, Faq, StickyCta, SuccessScreen, INST_ICON, InstIcon, areaText, useGps, useWizOn, WizardProgress, WaHelp, Sheet, Segmented, Stepper, AreaChooser, POI_IDS, }; })();