/* @jsx React.createElement */ /* ============================================================ INDIE Music — Jadi guru mitra IMS Home Private (kampanye + onboarding + formulir lamaran). React 18 UMD + Babel standalone, tanpa build. Fungsi bersama di hp-common.jsx (window.HP, dimuat duluan). Kontrak: ims-api routes/hp-public.js — GET /config, POST /apply (JSON, dokumen base64). Saklar hp_public_enabled mati -> /config 404 -> informasi tetap tampil, formulir diganti blok "Segera hadir" + WhatsApp admin. Kalkulator bagi hasil hanya tampil bila /config memberi harga. Pratinjau: ?demo=1 (data contoh, lihat hp-demo.js). TAHAP 3 (hp5): formulir lamaran = wizard 4 langkah (data diri → mengajar & wilayah → dokumen → referensi & kirim), tiap langkah ada jalan ke WA admin. Kontrak POST /apply TIDAK berubah. ============================================================ */ const { useState, useEffect, useRef } = React; const { waLink, rupiah, DEMO, AREAS, PHONE_RE, HHMM, normPhone, haversineKm, hpApi, friendlyError, useHpConfig, PREFERS_REDUCED, Reveal, HpMap, DemoBanner, DemoTag, HpNav, HpFooter, WAFloat, WaIcon, ComingSoon, Faq, StickyCta, SuccessScreen, InstIcon, areaText, useGps, useWizOn, WizardProgress, WaHelp, } = window.HP; const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; const MB = 1024 * 1024; const BODY_LIMIT = 20 * MB; // ims-api menerima body /apply maks 22 MB — sisakan ruang // Bagi hasil guru: /config tidak (belum) mengirim angka ini. 70% = keputusan terkonfirmasi Bram (SPEC §1, // PK pasal 5, default hp_teacher_share_pct ims-api/src/hp.js). Dipakai hanya bila /config tidak memberi teacher_share_pct. const SPEC_TEACHER_SHARE = 70; const teacherShare = (cfg) => (cfg && Number.isFinite(Number(cfg.teacher_share_pct)) ? Number(cfg.teacher_share_pct) : SPEC_TEACHER_SHARE); function ageOn(birth) { if (!/^\d{4}-\d{2}-\d{2}$/.test(birth || '')) return null; const [y, m, d] = birth.split('-').map(Number); const now = new Date(); const ty = now.getFullYear(); const tm = now.getMonth() + 1; const td = now.getDate(); return ty - y - (tm < m || (tm === m && td < d) ? 1 : 0); } const shortInst = (label) => String(label || '').split(' (')[0]; /* ───────────────────────────────────────────── Dokumen: cek tipe (magic bytes, sama dengan decodeDoc ims-api) + ukuran sebelum kirim. Foto besar / format HP (HEIC dsb.) dikompres jadi JPEG ≤1600 px di browser. ───────────────────────────────────────────── */ async function sniffType(blob) { const b = new Uint8Array(await blob.slice(0, 8).arrayBuffer()); if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'; if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a) return 'image/png'; if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) return 'application/pdf'; return null; } function compressImage(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { const scale = Math.min(1, 1600 / Math.max(img.naturalWidth, img.naturalHeight)); const c = document.createElement('canvas'); c.width = Math.max(1, Math.round(img.naturalWidth * scale)); c.height = Math.max(1, Math.round(img.naturalHeight * scale)); c.getContext('2d').drawImage(img, 0, 0, c.width, c.height); URL.revokeObjectURL(url); c.toBlob((b) => (b ? resolve(b) : reject(new Error('blob'))), 'image/jpeg', 0.85); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('decode')); }; img.src = url; }); } const toDataUrl = (blob) => new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = () => reject(new Error('read')); r.readAsDataURL(blob); }); // -> { dataUrl, size, type, name } atau throw Error(pesan ramah) async function prepareDoc(file, { label, allowPdf, maxMb }) { const max = maxMb * MB; const kinds = allowPdf ? 'JPG, PNG, atau PDF' : 'foto JPG atau PNG'; if (file.size > 15 * MB) throw new Error(label + ' terlalu besar (maks ' + maxMb + ' MB).'); let type = await sniffType(file); let blob = file; if (type === 'application/pdf') { if (!allowPdf) throw new Error(label + ' harus ' + kinds + ', bukan PDF.'); if (file.size > max) throw new Error(label + ' terlalu besar (maks ' + maxMb + ' MB). Perkecil PDF-nya atau kirim foto.'); } else if (type === 'image/jpeg' || type === 'image/png' || /^image\//.test(file.type)) { // Foto di atas 1,5 MB atau format lain (HEIC/WebP) -> JPEG terkompres. if (!type || file.size > 1.5 * MB) { try { blob = await compressImage(file); } catch (e) { if (!type) throw new Error(label + ': format tidak didukung. Pakai ' + kinds + '.'); throw new Error(label + ': foto tidak bisa dibaca. Coba foto ulang atau pakai file lain.'); } type = 'image/jpeg'; } if (blob.size > max) throw new Error(label + ' terlalu besar (maks ' + maxMb + ' MB).'); } else { throw new Error(label + ': format tidak didukung. Pakai ' + kinds + '.'); } const dataUrl = await toDataUrl(blob); return { dataUrl, size: blob.size, type, name: file.name }; } /* ───────────────────────────────────────────── Ketersediaan mingguan -> { mon: [["16:00","20:00"]], ... } (availabilityOrThrow di ims-api hp.js) ───────────────────────────────────────────── */ function AvailabilityEditor({ days, value, onChange, invalid }) { const toggle = (d, on) => { const v = { ...value }; if (on) v[d] = [['16:00', '20:00']]; else delete v[d]; onChange(v); }; const setRange = (d, i, j, t) => onChange({ ...value, [d]: value[d].map((r, k) => (k === i ? (j === 0 ? [t, r[1]] : [r[0], t]) : r)) }); const addRange = (d) => onChange({ ...value, [d]: [...value[d], ['18:00', '21:00']] }); const delRange = (d, i) => { const rs = value[d].filter((_, k) => k !== i); const v = { ...value }; if (rs.length) v[d] = rs; else delete v[d]; onChange(v); }; return (
{days.map((d) => { const on = !!value[d.key]; return (
{on && (
{value[d.key].map((r, i) => (
setRange(d.key, i, 0, e.target.value)} /> – setRange(d.key, i, 1, e.target.value)} />
))} {value[d.key].length < 4 && }
)}
); })}
); } function availabilityError(av, dayLabel) { let n = 0; for (const [d, ranges] of Object.entries(av)) { const sorted = ranges.slice().sort((a, b) => a[0].localeCompare(b[0])); for (const r of sorted) { if (!HHMM.test(r[0]) || !HHMM.test(r[1])) return 'Jam ' + dayLabel(d) + ' belum lengkap (HH:MM).'; if (r[0] >= r[1]) return 'Jam ' + dayLabel(d) + ': jam mulai harus sebelum jam selesai.'; if (r[0] < '06:00' || r[1] > '22:00') return 'Jam ' + dayLabel(d) + ': hanya 06:00–22:00.'; } for (let i = 1; i < sorted.length; i++) if (sorted[i][0] < sorted[i - 1][1]) return 'Jam ' + dayLabel(d) + ' tumpang tindih.'; n += ranges.length; } return n ? null : 'Isi minimal satu hari & jam ketersediaan.'; } /* ───────────────────────────────────────────── Radius & jangkauan — dihitung di perangkat dari titik + radius yang dipilih. ───────────────────────────────────────────── */ function ReachSummary({ loc, radius }) { if (!loc) return

Tandai titik tempat tinggal (lokasi perangkat, area, atau ketuk peta) untuk melihat area ajarmu.

; const r = Number(radius) || 0; const near = AREAS.map((a) => ({ a, km: haversineKm(loc, a) })).filter((x) => x.km <= r).sort((x, y) => x.km - y.km); return (
{r} km dari titikmu

Kamu akan ditawari murid dalam radius ini — lingkaran merah di peta. Kamu tetap boleh menolak tawaran.

{near.length ? 'Pusat area dalam jangkauan: ' + near.map((x) => x.a.short + ' (±' + Math.round(x.km * 10) / 10 + ' km)').join(', ') + '.' : 'Belum ada pusat area pilot/sekitarnya dalam radius ini — perbesar radius bila sanggup, atau pilih titik yang lebih tepat.'}

); } /* ───────────────────────────────────────────── WIZARD LAMARAN — 4 langkah → POST /apply (kontrak hp-public.js, field & tipe tidak berubah). ───────────────────────────────────────────── */ const DOCS = [ { key: 'ktp', label: 'Foto KTP', required: true, allowPdf: false, accept: 'image/jpeg,image/png,image/*', hint: 'Foto jelas, semua tulisan terbaca.' }, { key: 'selfie', label: 'Foto selfie', required: true, allowPdf: false, accept: 'image/jpeg,image/png,image/*', capture: 'user', hint: 'Wajah jelas, tanpa masker/kacamata hitam.' }, { key: 'skck', label: 'SKCK', required: false, allowPdf: true, accept: 'image/jpeg,image/png,application/pdf,image/*', hint: 'Foto atau PDF. Boleh menyusul — wajib sebelum kamu aktif mengajar.' }, ]; // Ketersediaan cepat: chip hari + rentang waktu → { mon: [["16:00","22:00"]], ... } (rentang bersebelahan digabung). const RANGES = [ { k: 'pagi', label: 'Pagi', from: '08:00', to: '12:00' }, { k: 'siang', label: 'Siang', from: '12:00', to: '16:00' }, { k: 'sore', label: 'Sore', from: '16:00', to: '19:00' }, { k: 'malam', label: 'Malam', from: '19:00', to: '22:00' }, ]; const DAY_SHORT = { mon: 'Sen', tue: 'Sel', wed: 'Rab', thu: 'Kam', fri: 'Jum', sat: 'Sab', sun: 'Min' }; function quickAvailability(days, ranges) { const rs = RANGES.filter((r) => ranges.includes(r.k)).map((r) => [r.from, r.to]); const merged = []; rs.forEach((r) => { const l = merged[merged.length - 1]; if (l && l[1] === r[0]) l[1] = r[1]; else merged.push([r[0], r[1]]); }); const out = {}; if (merged.length) days.forEach((d) => { out[d] = merged.map((r) => [r[0], r[1]]); }); return out; } const AP_INIT = { full_name: '', phone: '', email: '', birth_date: '', area_label: '', radius_km: 10, instruments: [], experience: '', bio: '', kids_ok: false, days: [], ranges: [], availMode: 'quick', availability: {}, refs: [{ name: '', phone: '', relation: '' }], consent: false, website: '', company_url: '', }; /* Pesan WA dari data yang SUDAH diisi — tanpa dokumen, tanpa tanggal lahir/KTP, tanpa koordinat. */ function guruWaText(cfg, f, loc) { const parts = ['Halo IMS! Aku mau daftar jadi guru mitra Home Private, lebih gampang lewat chat.']; if (f.full_name.trim()) parts.push('Nama: ' + f.full_name.trim().slice(0, 80) + '.'); if (f.instruments.length) parts.push('Bisa mengajar: ' + f.instruments.map((k) => { const i = cfg.instruments.find((x) => x.key === k); return i ? shortInst(i.label) : k; }).join(', ') + '.'); const area = f.area_label.trim() || areaText(loc); if (area) parts.push('Area tinggal: ' + area.slice(0, 80) + (loc ? ' (radius ' + f.radius_km + ' km)' : '') + '.'); const days = f.availMode === 'detail' ? Object.keys(f.availability) : f.days; if (days.length) parts.push('Hari bisa mengajar: ' + cfg.days.filter((d) => days.includes(d.key)).map((d) => d.label).join(', ') + '.'); if (!f.full_name.trim() && !f.instruments.length) parts.push('Aku bisa mengajar: '); return parts.join(' '); } function ApplyWizard({ cfg, onClosed }) { const maxMb = cfg.doc_max_mb || 5; const maxR = cfg.max_radius_km; const [step, setStep] = useState(1); const [f, setF] = useState(() => ({ ...AP_INIT, radius_km: Math.min(10, maxR) })); const [loc, setLoc] = useState(null); const [docs, setDocs] = useState({}); const [docErr, setDocErr] = useState({}); const [docBusy, setDocBusy] = useState({}); const [errors, setErrors] = useState({}); const [formErr, setFormErr] = useState(null); const [busy, setBusy] = useState(false); const [done, setDone] = useState(null); const top = useRef(null); const head = useRef(null); const first = useRef(true); const set = (k, v) => { setF((x) => ({ ...x, [k]: v })); if (errors[k]) setErrors((e) => { const c = { ...e }; delete c[k]; return c; }); }; const dayLabel = (k) => { const d = cfg.days.find((x) => x.key === k); return d ? d.label : k; }; const instLabel = (k) => { const i = cfg.instruments.find((x) => x.key === k); return i ? shortInst(i.label) : k; }; const gps = useGps((l) => { setLoc(l); if (errors.loc) set('loc', undefined); }); useWizOn(top); const TITLES = ['Kenalan dulu', 'Kamu mengajar apa & di mana?', 'Dokumen verifikasi', 'Referensi & kirim']; const availability = f.availMode === 'detail' ? f.availability : quickAvailability(f.days, f.ranges); useEffect(() => { if (first.current) { first.current = false; return; } if (head.current) head.current.focus({ preventScroll: true }); const r = top.current && top.current.getBoundingClientRect(); if (r && (r.top < 0 || r.top > window.innerHeight * 0.4)) top.current.scrollIntoView({ behavior: PREFERS_REDUCED ? 'auto' : 'smooth', block: 'start' }); }, [step, !!done]); const onFile = async (d, file) => { setDocErr((x) => ({ ...x, [d.key]: null })); if (errors['doc_' + d.key]) setErrors((e) => { const c = { ...e }; delete c['doc_' + d.key]; return c; }); if (!file) { setDocs((x) => ({ ...x, [d.key]: null })); return; } setDocBusy((x) => ({ ...x, [d.key]: true })); try { const p = await prepareDoc(file, { label: d.label, allowPdf: d.allowPdf, maxMb }); setDocs((x) => ({ ...x, [d.key]: p })); } catch (e) { setDocs((x) => ({ ...x, [d.key]: null })); setDocErr((x) => ({ ...x, [d.key]: e.message })); } finally { setDocBusy((x) => ({ ...x, [d.key]: false })); } }; const validate = (s) => { const e = {}; if (s === 1) { if (f.full_name.trim().length < 3) e.full_name = 'Tulis nama lengkap sesuai KTP (minimal 3 huruf).'; if (!PHONE_RE.test(normPhone(f.phone))) e.phone = f.phone.trim() ? 'Nomor WhatsApp belum benar. Contoh: 0812 3456 7890.' : 'Isi nomor WhatsApp aktif.'; if (!EMAIL_RE.test(f.email.trim())) e.email = f.email.trim() ? 'Format email belum benar, contoh nama@email.com.' : 'Isi email aktif (untuk akun dashboard mitra).'; const age = ageOn(f.birth_date); if (age === null) e.birth_date = 'Isi tanggal lahir.'; else if (age < 18 || age > 80) e.birth_date = 'Usia pelamar minimal 18 tahun.'; } else if (s === 2) { if (!f.instruments.length) e.instruments = 'Pilih minimal satu alat musik.'; if (f.experience.trim().length < 20) e.experience = 'Ceritakan sedikit pengalaman mengajar/bermusik (minimal 20 karakter).'; if (!loc) e.loc = 'Tandai titik dekat rumahmu — pakai lokasi saya, pilih area, atau ketuk peta.'; const r = Number(f.radius_km); if (!(r > 0 && r <= maxR)) e.radius_km = 'Radius 1–' + maxR + ' km.'; if (f.area_label.trim().length < 3) e.area_label = 'Isi kelurahan/kecamatan tempat tinggal.'; if (f.availMode === 'quick') { if (!f.days.length) e.availability = 'Pilih hari kamu bisa mengajar.'; else if (!f.ranges.length) e.availability = 'Pilih jam kamu bisa mengajar (pagi/siang/sore/malam).'; } else { const av = availabilityError(f.availability, dayLabel); if (av) e.availability = av; } } else if (s === 3) { for (const d of DOCS) { if (docErr[d.key]) e['doc_' + d.key] = docErr[d.key]; else if (d.required && !docs[d.key]) e['doc_' + d.key] = d.label + ' wajib diunggah.'; } } else if (s === 4) { const refs = f.refs.filter((x) => x.name.trim() || x.phone.trim() || x.relation.trim()); if (!refs.length) e.refs = 'Isi minimal satu referensi.'; else { for (let i = 0; i < refs.length; i++) { if (refs[i].name.trim().length < 2) { e.refs = 'Referensi ' + (i + 1) + ': nama wajib.'; break; } if (!PHONE_RE.test(normPhone(refs[i].phone))) { e.refs = 'Referensi ' + (i + 1) + ': nomor belum benar (contoh 0812xxxxxxx).'; break; } } } if (!f.consent) e.consent = 'Centang persetujuan data pribadi untuk mengirim.'; } return e; }; const FIRST = { full_name: 'ap-name', phone: 'ap-phone', email: 'ap-email', birth_date: 'ap-birth', instruments: 'ap-inst', experience: 'ap-exp', loc: 'ap-gps', radius_km: 'ap-radius', area_label: 'ap-area-label', availability: 'ap-avail', doc_ktp: 'ap-doc-ktp', doc_selfie: 'ap-doc-selfie', doc_skck: 'ap-doc-skck', refs: 'ap-ref-0-name', consent: 'ap-consent' }; const check = (s) => { if (s === 3 && Object.values(docBusy).some(Boolean)) { setErrors({ docs: 'Tunggu sebentar, dokumen masih diproses.' }); return false; } const e = validate(s); setErrors(e); const keys = Object.keys(e); if (keys.length) { setTimeout(() => { const el = document.getElementById(FIRST[keys[0]]); if (el) el.focus(); }, 0); return false; } return true; }; const next = () => { if (check(step)) setStep(step + 1); }; const back = () => { setErrors({}); setFormErr(null); setStep(step - 1); }; const submit = async () => { setFormErr(null); for (const s of [1, 2, 3, 4]) { if (!check(s)) { setStep(s); return; } } const body = { full_name: f.full_name.trim(), phone: f.phone.trim(), email: f.email.trim(), birth_date: f.birth_date, area_label: f.area_label.trim(), lat: loc.lat, lng: loc.lng, radius_km: Number(f.radius_km), instruments: f.instruments, availability, refs: f.refs.filter((x) => x.name.trim() || x.phone.trim()).map((x) => ({ name: x.name.trim(), phone: x.phone.trim(), relation: x.relation.trim() })), experience: f.experience.trim(), bio: f.bio.trim(), kids_ok: f.kids_ok, ktp: docs.ktp.dataUrl, selfie: docs.selfie.dataUrl, consent: true, consent_version: cfg.consent.version, website: f.website, // honeypot company_url: f.company_url, // honeypot }; if (docs.skck) body.skck = docs.skck.dataUrl; const size = (body.ktp.length + body.selfie.length + (body.skck ? body.skck.length : 0)); if (size > BODY_LIMIT) { setFormErr('Total dokumen terlalu besar untuk sekali kirim. Perkecil PDF SKCK atau kirim SKCK menyusul.'); return; } setBusy(true); const r = await hpApi('/apply', { method: 'POST', body, timeout: 90000 }); setBusy(false); if (r.ok && r.body && r.body.ok) { setDone(r.body.message || 'Lamaran terkirim.'); return; } if (r.status === 404) { onClosed(); return; } setFormErr(friendlyError(r, 'apply', maxMb)); }; const waText = guruWaText(cfg, f, loc); if (done) { const days = cfg.days.filter((d) => availability[d.key]).map((d) => d.label); return (
x.name.trim()).length + ' orang'], ['Kontak', f.phone.trim() + ' · ' + f.email.trim()], ]} steps={[ 'Seleksi dokumen: KTP & selfie dicek, referensi dihubungi.', 'Wawancara — onsite, Zoom, atau Google Meet.', 'Demo mengajar, lalu pelatihan Metode IMS + tes.', 'Tanda tangan Perjanjian Kemitraan → akun dashboard mitra aktif, mulai menerima tawaran murid.', ]} note={docs.skck ? 'Tim IMS menghubungi lewat WhatsApp bila profilmu cocok.' : 'SKCK boleh menyusul — wajib berlaku sebelum kamu aktif mengajar. Tim IMS menghubungi lewat WhatsApp bila profilmu cocok.'} waText={'Halo IMS! Aku barusan kirim lamaran guru mitra Home Private atas nama ' + f.full_name.trim() + '.'} />
); } const inv = (k, id, hint) => ({ id, 'aria-invalid': errors[k] ? true : undefined, 'aria-describedby': errors[k] ? id + '-err' : hint }); const err = (k, id) => (errors[k] ?
{errors[k]}
: null); const toggle = (k, v) => set(k, f[k].includes(v) ? f[k].filter((x) => x !== v) : [...f[k], v]); const setRef = (i, patch) => set('refs', f.refs.map((x, j) => (j === i ? { ...x, ...patch } : x))); const presets = [...new Set([3, 5, 10, maxR].filter((v) => v <= maxR))]; const refsN = f.refs.filter((x) => x.name.trim()).length; const availDays = cfg.days.filter((d) => availability[d.key]); const summary = [ ['Nama', f.full_name.trim()], ['Kontak', f.phone.trim() + (f.email.trim() ? ' · ' + f.email.trim() : '')], ['Mengajar', f.instruments.map(instLabel).join(', ') + (f.kids_ok ? ' · bisa ajar anak' : '')], ['Wilayah', (f.area_label.trim() || areaText(loc)) + ' · radius ' + f.radius_km + ' km'], ['Jadwal', availDays.map((d) => DAY_SHORT[d.key] + ' ' + availability[d.key].map((r) => r[0] + '–' + r[1]).join(', ')).join(' · ')], ['Dokumen', 'KTP ' + (docs.ktp ? '✓' : '—') + ' · Selfie ' + (docs.selfie ? '✓' : '—') + ' · SKCK ' + (docs.skck ? '✓' : 'menyusul')], ]; return (

{TITLES[step - 1]}

{step === 1 && (

Sesuai KTP. Email dipakai untuk akun dashboard mitra bila kamu diterima.

set('full_name', e.target.value)} autoComplete="name" enterKeyHint="next" /> {err('full_name', 'ap-name')}
set('phone', e.target.value)} placeholder="0812 3456 7890" autoComplete="tel" enterKeyHint="next" /> {err('phone', 'ap-phone')}
set('email', e.target.value)} placeholder="nama@email.com" autoComplete="email" enterKeyHint="next" /> {err('email', 'ap-email')}
set('birth_date', e.target.value)} autoComplete="bday" /> {errors.birth_date ? err('birth_date', 'ap-birth') : Minimal 18 tahun.}
)} {step === 2 && (
Alat musik yang bisa kamu ajarkan
{cfg.instruments.map((i) => ( ))}
{errors.instruments ? err('instruments', 'ap-inst') : Boleh lebih dari satu. Drum di rumah hanya drum pad / practice kit.}