);
}
/* Cek jangkauan — dihitung di perangkat dari pusat area pilot, lokasi tidak dikirim ke server. */
function CoverageCheck({ loc, maxR }) {
const c = pilotCheck(loc, maxR);
if (!c) return null;
const where = c.area.short;
if (c.tier === 'in') {
return (
✓ Masuk area pilot
{c.km < 0.5 ? 'Di sekitar pusat ' + where : '±' + c.km + ' km dari pusat ' + where}. Lanjut pilih alat musik.
);
}
const wa = 'Halo IMS! Aku tertarik les musik di rumah, tapi lokasiku di luar area pilot (sekitar ' + Math.round(c.km) + ' km dari ' + where + '). Kabari aku kalau areaku sudah dibuka ya. Area aku: ';
if (c.tier === 'edge') {
return (
~ Pinggir area pilot
±{c.km} km dari pusat {where}. Masih mungkin terjangkau bila ada guru yang radiusnya sampai — lanjut saja, atau tetap kirim permintaan.
);
}
return (
! Belum terjangkau
±{c.km} km dari area pilot terdekat ({where}). Tinggalkan nomor WA ke admin — kami kabari saat areamu dibuka.
{ setLoc({ ...p, source: 'map', label: 'Titik yang kamu ketuk di peta' }); setStepErr(null); }}
label="Peta: ketuk untuk memilih lokasi les. Pilihan area di atas berisi informasi yang sama." describedBy="wz-map1-legend"
legend={[{ sw: 'pin', text: 'Lokasi kamu' }, { sw: 'pilot', text: 'Area pilot' }]} />
setSel(null)} pilotZone fitKey={fitKey}
onLocate={gps.run} locating={gps.busy}
label="Peta hasil: badge bernomor = area kira-kira guru (±1 km, bukan alamat), sama dengan nomor di daftar."
describedBy="wz-map-legend"
legend={[{ sw: 'pin', text: 'Lokasi kamu' }, { sw: 'zone', text: 'Area guru ±1 km (bukan alamat)' }, { sw: 'pilot', text: 'Area pilot' }]}>
{selTeachers.length > 0 && (
{selTeachers.map(({ t, num }) => (
{num}
{t.name}±{t.distance_km} km · {(t.instruments || []).map((x) => instName(cfg, x)).join(', ')}
setPicked(null)}
rq={rq} setRq={setRq} onClosed={onClosed}
onClose={(done) => {
setSheet(false);
if (done) { setSentNote('Permintaan terkirim. Tim IMS akan menghubungi lewat WhatsApp setelah guru yang cocok ditemukan.'); setRq(RQ_INIT); setPicked(null); }
}} />
);
}
/* ─────────────────────────────────────────────
LEMBAR PERMINTAAN — 4 langkah → POST /request (kontrak createHpRequest di hp-public.js, tidak berubah).
───────────────────────────────────────────── */
function RequestSheet({ open, onClose, cfg, loc, setLoc, q, picked, clearPicked, rq, setRq, onClosed }) {
const [step, setStep] = useState(1);
const [errors, setErrors] = useState({});
const [formErr, setFormErr] = useState(null);
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(null);
const [editInst, setEditInst] = useState(false);
const head = useRef(null);
const body = useRef(null);
const set = (k, v) => { setRq((x) => ({ ...x, [k]: v })); if (errors[k]) setErrors((e) => { const c = { ...e }; delete c[k]; return c; }); };
const times = timesFor(cfg);
const ps = cfg.package_sessions || 4;
const hasPrice = cfg.prices && (Number(cfg.prices.paket) > 0 || Number(cfg.prices.eceran) > 0);
const TITLES = ['Siapa yang mau les?', 'Kapan bisanya?', 'Ke mana guru datang?', 'Cek & kirim'];
useEffect(() => { if (open) { setStep(1); setErrors({}); setFormErr(null); setDone(null); setEditInst(false); } }, [open]);
useEffect(() => {
if (!open) return;
if (body.current) body.current.scrollTop = 0;
const t = setTimeout(() => head.current && head.current.focus({ preventScroll: true }), 30);
return () => clearTimeout(t);
}, [open, step]);
const validate = (s) => {
const e = {};
if (s === 1) {
if (!cfg.instruments.some((i) => i.key === rq.instrument)) e.instrument = 'Pilih alat musik.';
if (rq.student_name.trim().length < 2) e.student_name = 'Tulis nama murid (minimal 2 huruf).';
const age = Number(rq.student_age);
if (rq.student_age === '' || !Number.isInteger(age) || age < 3 || age > 90) e.student_age = 'Usia murid 3–90 tahun.';
} else if (s === 2) {
if (rq.days.length < 2) e.days = rq.days.length ? 'Pilih 1 hari lagi — minimal 2 hari supaya guru lebih mudah dicocokkan.' : 'Pilih minimal 2 hari yang bisa.';
if (!times.some((t) => t.k === rq.time)) e.time = 'Pilih waktu les.';
} else if (s === 3) {
if (rq.parent_name.trim().length < 2) e.parent_name = 'Tulis nama orang tua/wali (minimal 2 huruf).';
if (!PHONE_RE.test(normPhone(rq.parent_phone))) e.parent_phone = rq.parent_phone.trim() ? 'Nomor WhatsApp belum benar. Contoh: 0812 3456 7890.' : 'Isi nomor WhatsApp untuk dihubungi admin.';
if (rq.address.trim().length < 10) e.address = 'Tulis alamat lengkap (jalan, nomor, cluster) — minimal 10 karakter.';
if (!loc) e.loc = 'Lokasi les belum dipilih. Kembali ke pencarian untuk memilih lokasi.';
} else if (s === 4) {
if (!['paket', 'eceran'].includes(rq.kind)) e.kind = 'Pilih paket atau eceran.';
if (!rq.consent) e.consent = 'Centang persetujuan data pribadi untuk mengirim.';
}
return e;
};
const FOCUS = { instrument: 'rq-inst', student_name: 'rq-name', student_age: 'rq-age', days: 'rq-days', time: 'rq-time', parent_name: 'rq-pname', parent_phone: 'rq-phone', address: 'rq-addr', loc: 'rq-addr', kind: 'rq-kind', consent: 'rq-consent' };
const check = (s) => {
const e = validate(s);
setErrors(e);
const keys = Object.keys(e);
if (keys.length) {
if (keys.includes('instrument')) setEditInst(true);
setTimeout(() => { const el = document.getElementById(FOCUS[keys[0]]); if (el) el.focus(); }, 0);
return false;
}
return true;
};
const next = () => { if (check(step)) { setErrors({}); setStep(step + 1); } };
const back = () => { setErrors({}); setFormErr(null); if (step > 1) setStep(step - 1); else onClose(false); };
const slots = cfg.days.filter((d) => rq.days.includes(d.key)).map((d) => ({ day: d.key, start: (times.find((t) => t.k === rq.time) || {}).start }));
const submit = async () => {
setFormErr(null);
for (const s of [1, 2, 3, 4]) { if (!check(s)) { setStep(s); return; } }
setBusy(true);
const payload = {
instrument: rq.instrument,
student_name: rq.student_name.trim(),
student_age: Number(rq.student_age),
level: rq.level,
lat: loc.lat,
lng: loc.lng,
area_label: rq.area_label.trim(),
address: rq.address.trim(),
slots: slots.map((s) => ({ day: s.day, start: s.start })),
kind: rq.kind,
parent_name: rq.parent_name.trim(),
parent_phone: rq.parent_phone.trim(),
note: rq.note.trim(),
preferred_teacher_id: picked ? picked.id : null,
consent: true,
consent_version: cfg.parent_consent.version,
website: rq.website, // honeypot — manusia tidak pernah mengisi
};
const r = await hpApi('/request', { method: 'POST', body: payload, timeout: 20000 });
setBusy(false);
if (r.ok && r.body && r.body.ok) { setDone(r.body.message || 'Permintaan terkirim.'); return; }
if (r.status === 404) { onClose(false); onClosed(); return; }
setFormErr(friendlyError(r));
};
const waText = lesWaText({ cfg, q, loc, rq, picked });
const Err = ({ k, id }) => (errors[k] ?
set('student_age', v)} min={3} max={90} label="usia murid" invalid={!!errors.student_age} describedBy={errors.student_age ? 'rq-age-err' : undefined} />
{age > 0 && age < 17 && Murid di bawah 17 tahun: orang dewasa wajib ada di rumah selama sesi.}
Level
{(cfg.levels || ['pemula']).map((l) => )}
)}
{step === 2 && (
Hari yang bisa (pilih minimal 2)
{cfg.days.map((d) => (
))}
Jam berapa?
{times.map((t) => (
))}
Sesi {cfg.session_minutes} menit. Jadwal pasti dikonfirmasi admin lewat WhatsApp — ini hanya pilihan awal.
set('parent_phone', e.target.value)} placeholder="0812 3456 7890" autoComplete="tel" enterKeyHint="next" />
{errors.parent_phone ? : Admin IMS menghubungi lewat nomor ini. Tidak diberikan ke guru.}
set('area_label', e.target.value)} placeholder="mis. Pakulonan Barat, Kelapa Dua" />
);
}
/* "Perjalanan satu sesi" — garis progres terisi saat scroll (tanpa animasi bila reduced-motion).
Isi hanya fakta SPEC §4–5 + hp-public.js (konfirmasi ortu, nilai 1–5, auto-konfirmasi, catatan guru). */
function SessionJourney({ minutes }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return undefined;
if (PREFERS_REDUCED) {
el.style.setProperty('--p', '1');
el.querySelectorAll('.hp-journey__step').forEach((s) => s.classList.add('is-lit'));
return undefined;
}
let raf = 0;
const update = () => {
raf = 0;
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
const p = Math.max(0, Math.min(1, (vh * 0.62 - r.top) / Math.max(1, r.height)));
el.style.setProperty('--p', p.toFixed(3));
el.querySelectorAll('.hp-journey__step').forEach((s) => {
const sr = s.getBoundingClientRect();
s.classList.toggle('is-lit', sr.top < vh * 0.62);
});
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); if (raf) cancelAnimationFrame(raf); };
}, []);
const steps = [
{ t: 'Pilih guru', d: 'Dari daftar "Cari guru terdekat", atau serahkan ke IMS untuk dicarikan. Guru tetap boleh menerima atau menolak.', k: 'Kamu' },
{ t: 'Konfirmasi', d: 'IMS menawarkan ke guru, guru menerima, lalu kamu konfirmasi jadwal lewat WhatsApp. Alamat lengkap baru dibuka ke guru setelah penugasan dikonfirmasi.', k: 'IMS · guru · kamu' },
{ t: 'Guru datang & check-in', d: 'Saat tiba, guru check-in dengan GPS + foto di sistem IMS. Untuk murid di bawah 17 tahun, orang dewasa ada di rumah.', k: 'Guru' },
{ t: 'Belajar ' + minutes + ' menit', d: 'Sesi pakai Metode IMS. Selesai sesi, guru check-out dan mengisi catatan progres.', k: 'Guru · murid' },
{ t: 'Ortu konfirmasi + nilai', d: 'Kamu dapat tautan untuk konfirmasi sesi dan memberi nilai 1–5. Tanpa respons dalam 24 jam, sesi dianggap terkonfirmasi. Ada masalah? Lapor lewat tautan yang sama.', k: 'Kamu' },
{ t: 'Progres tercatat', d: 'Catatan guru, milestone, dan skor skill tercatat di platform IMS — ikut tampil di halaman konfirmasi sesimu.', k: 'Sistem IMS' },
];
return (
{steps.map((s, i) => (
{i + 1}
{s.k}
Tahap {i + 1}: {s.t}
{s.d}
))}
);
}
function SafetyGrid({ minutes }) {
const items = [
{ t: 'Guru terverifikasi IMS', d: 'Sebelum aktif: cek KTP + selfie, SKCK, referensi, wawancara, demo mengajar, pelatihan Metode IMS + tes, lalu tanda tangan perjanjian kemitraan.' },
{ t: 'Check-in & check-out GPS', d: 'Guru check-in di lokasi (GPS + foto) saat datang dan check-out saat selesai. Durasi sesi ' + minutes + ' menit.' },
{ t: 'Kamu konfirmasi tiap sesi', d: 'Setelah sesi, kamu dapat tautan untuk konfirmasi & memberi nilai 1–5. Tanpa respons dalam 24 jam, sesi dianggap terkonfirmasi.' },
{ t: 'Ada jalur lapor', d: 'Ada yang tidak beres? Laporkan lewat tautan sesi yang sama. Laporan masuk ke antrean tim IMS untuk ditindaklanjuti.' },
{ t: 'Data kamu dijaga', d: 'Alamat lengkap hanya dibuka untuk guru yang sudah ditetapkan IMS. Nomor WhatsApp kamu tidak diberikan ke guru — komunikasi lewat IMS.' },
{ t: 'Murid anak = ada orang dewasa', d: 'Untuk murid di bawah 17 tahun, orang dewasa wajib ada di rumah selama sesi berlangsung.' },
];
return (
{items.map((x, i) => (
0{i + 1}
{x.t}
{x.d}
))}
);
}
function faqItems(cfg) {
const open = !!cfg;
const radius = open ? cfg.max_radius_km : 15;
return [
{
q: 'Berapa biayanya?',
a: open
? 'Paket ' + (cfg.package_sessions || 4) + ' sesi ' + rupiah(cfg.prices.paket) + ' (idealnya habis dalam 1 bulan), atau eceran ' + rupiah(cfg.prices.eceran) + ' per sesi. Satu sesi ' + cfg.session_minutes + ' menit. Pembayaran ke IMS — jangan bayar langsung ke guru.' + (DEMO ? ' (Angka di pratinjau ini contoh.)' : '')
: 'Harga Home Private kami umumkan saat layanan dibuka di area pilot. Untuk info terbaru, chat admin IMS. Yang pasti: pembayaran selalu ke IMS, bukan langsung ke guru.',
},
{
q: 'Kuota paket & pembatalan gimana?',
a: 'Paket 4 sesi idealnya selesai dalam sebulan; kuota berlaku dalam jendela 2 bulan — sesi yang belum dipakai sampai jendela berakhir hangus (aturan sama dengan kelas studio IMS). Pembatalan mendadak (kurang dari 3 jam sebelum sesi) dapat dihitung sesi hangus. Detail reschedule dikonfirmasi admin sebelum sesi pertama.',
},
{
q: 'Seberapa aman guru datang ke rumah?',
a: 'Guru lolos verifikasi (identitas, SKCK, referensi, wawancara, demo, pelatihan Metode IMS) dan terikat perjanjian kemitraan. Tiap sesi ada check-in/out GPS, kamu yang konfirmasi sesinya, dan ada jalur lapor insiden. Untuk murid di bawah 17 tahun, orang dewasa wajib ada di rumah.',
},
{
q: 'Wilayah mana saja?',
a: 'Mulai dari klaster pilot Gading Serpong – BSD – Alam Sutera. Guru memilih radius mengajarnya sendiri, maksimal ' + radius + ' km dari area tinggalnya. Di luar area itu? Tetap kirim permintaan atau chat admin — kalau belum ada guru, kelas di studio IMS Gading Serpong selalu bisa.',
},
{
q: 'Alat musik apa saja?',
a: 'Untuk les di rumah: ' + (open ? cfg.instruments.map((i) => i.label.toLowerCase()).join(', ') : INSTRUMENT_TEXT) + '. Alat musik disiapkan murid. Drum kit penuh tidak realistis di rumah — untuk drum kit, ikut kelas di studio IMS.',
},
{
q: 'Bisa pilih guru sendiri?',
a: 'Bisa pilih dari daftar "Cari guru terdekat" — kami prioritaskan. Guru tetap bisa menerima atau menolak sesuai jadwalnya; kalau berhalangan, IMS mencarikan guru lain yang cocok.',
},
];
}
/* Cara kerja — ringkas (4 langkah). */
function Steps() {
const steps = [
{ title: 'Pilih lokasi & alat musik', desc: 'Lokasi les, alat musik, dan usia murid. Tiga ketukan.' },
{ title: 'Lihat guru terdekat', desc: 'Guru terverifikasi IMS yang radiusnya sampai — area kira-kira ±1 km, tanpa alamat atau nomor guru.' },
{ title: 'Kirim permintaan', desc: 'Nama murid, hari yang bisa, nomor WhatsApp. Belum ada pembayaran.' },
{ title: 'IMS menghubungi', desc: 'Kami cocokkan guru (alat, jarak, jadwal), lalu kamu konfirmasi sesi pertama lewat WhatsApp.' },
];
return (
{steps.map((st, i) => (
{i + 1}