/* @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 (
);
}
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 = ''
+ ''
+ 'Lokasi kamu';
/* 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 (