/* @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() + '.'}
/>
Hanya untuk verifikasi identitas & keselamatan murid. Disimpan privat, hanya dibuka tim verifikasi IMS, setiap akses tercatat. Maks {maxMb} MB per berkas — foto besar dikecilkan otomatis.
{DOCS.map((d) => {
const id = 'ap-doc-' + d.key;
const e = errors['doc_' + d.key] || docErr[d.key];
const p = docs[d.key];
return (
setPerWeek(Number(e.target.value))} aria-valuetext={perWeek + ' sesi per minggu'} />
Nilai per sesi: {kind === 'paket' ? rupiah(pk) + ' ÷ ' + ps + ' = ' : ''}{rupiah(Math.round(perSession))}
Bagian guru {share}%: {rupiah(honor)} per sesi selesai
{perWeek} sesi × {rupiah(honor)} = {rupiah(week)} per minggu
Ilustrasi 4 minggu
{rupiah(four)}
{perWeek * 4} sesi selesai × {rupiah(honor)}
Ilustrasi perhitungan, bukan janji penghasilan. Jumlah murid tidak dijamin — tergantung permintaan di areamu
dan jadwal yang kamu buka. Ketentuan resmi mengikuti Perjanjian Kemitraan.
);
}
/* ─────────────────────────────────────────────
Konten kampanye (tetap tampil walau pendaftaran belum dibuka).
───────────────────────────────────────────── */
function Reasons({ maxR, share }) {
const items = [
{ t: 'Jadwal & radius kamu yang atur', d: 'Kamu isi ketersediaan mingguan dan radius sendiri (maks ' + maxR + ' km dari rumah). Tawaran murid boleh kamu tolak tanpa sanksi.' },
{ t: 'Murid & jadwal dari IMS', d: 'Permintaan ortu masuk ke IMS. Sistem merekomendasikan guru yang cocok (alat musik, jarak, jadwal), admin menawarkan, kamu yang memutuskan terima.' },
{ t: 'Bagi hasil ' + share + ' / ' + (100 - share), d: share + '% untuk kamu, ' + (100 - share) + '% untuk IMS, dari nilai yang dibayar murid ke IMS — dihitung per sesi yang selesai.' },
{ t: 'Kurikulum & metode seragam', d: 'Pakai Metode IMS — kurikulum, rapor progres tiap sesi (skill, milestone, stamp) — dengan pelatihan saat onboarding. Kamu fokus ngajar, nggak bikin materi dari nol.' },
{ t: 'Honor tercatat di dashboard', d: 'Tiap sesi yang dikonfirmasi tercatat di dashboard mitra. Pencairan tiap 4 sesi selesai (paket) atau akhir bulan (eceran).' },
{ t: 'Nggak perlu nagih', d: 'Ortu bayar ke IMS, bukan ke kamu. Urusan tagihan, jadwal ulang, dan guru pengganti dibantu admin IMS.' },
];
return (
{items.map((x, i) => (
0{i + 1}
{x.t}
{x.d}
))}
);
}
/* Stepper onboarding interaktif (pola tab; panah atas/bawah/kiri/kanan berpindah langkah).
Urutan & isi = SPEC:43 + CHECKLIST ims-api hp.js + Perjanjian Kemitraan outline. Tanpa estimasi durasi (belum ditetapkan). */
const ONBOARD = [
{ t: 'Daftar online', d: 'Isi formulir di halaman ini: data diri, dokumen, alat musik, wilayah & radius, jadwal, referensi.',
what: ['Lamaranmu masuk ke daftar pelamar IMS.', 'Dokumen disimpan di penyimpanan privat — hanya tim verifikasi yang bisa membuka, setiap akses tercatat.'],
prep: ['Foto KTP & selfie (JPG/PNG)', 'SKCK (boleh menyusul)', '1–3 referensi', 'Jadwal mingguan, titik tempat tinggal & radius'] },
{ t: 'Seleksi dokumen', d: 'Tim IMS memeriksa KTP & selfie, lalu menghubungi referensimu. SKCK boleh menyusul, tapi wajib berlaku sebelum kamu aktif.', tag: 'KTP · Selfie · SKCK · Referensi',
what: ['KTP diverifikasi dan selfie dicocokkan dengan KTP.', 'Referensi dihubungi soal pengalaman mengajarmu.', 'SKCK dicek masih berlaku.'],
prep: ['Kabari referensimu bahwa IMS mungkin menghubungi mereka.', 'Urus SKCK kalau belum punya.'] },
{ t: 'Wawancara', d: 'Ngobrol santai soal pengalaman dan cara mengajarmu — tatap muka (onsite) atau online lewat Zoom / Google Meet.', tag: 'Onsite · Zoom · Meet',
what: ['Ngobrol soal pengalaman, alat musik, dan cara kamu mengajar.'],
prep: ['Pilih format: onsite, Zoom, atau Google Meet.'] },
{ t: 'Demo mengajar', d: 'Tunjukkan cara kamu mengajar satu materi. Bukan audisi bintang — kami lihat cara kamu menjelaskan.',
what: ['Kamu mengajarkan satu materi; tim IMS menilai cara kamu menjelaskan.'],
prep: ['Siapkan satu materi singkat untuk alat musikmu.'] },
{ t: 'Pelatihan Metode IMS + tes', d: 'Kenalan dengan kurikulum, rapor progres, dan standar sesi IMS, ditutup tes singkat.',
what: ['Pelatihan kurikulum Metode IMS dan rapor progres (skill, milestone, stamp) yang diisi tiap sesi.', 'Ditutup tes singkat.'],
prep: [] },
{ t: 'Perjanjian kemitraan', d: 'Kamu baca dan tanda tangan Perjanjian Kemitraan — termasuk larangan mengambil murid IMS di luar platform selama terdaftar + 12 bulan sesudahnya.', tag: 'Non-solicit 12 bulan',
what: ['Isi perjanjian: status mitra (bukan karyawan), bagi hasil, pembayaran hanya lewat IMS, perlindungan data murid, non-solicit 12 bulan.'],
prep: ['Baca dengan teliti; tanyakan yang belum jelas sebelum tanda tangan.'] },
{ t: 'Aktif & mulai menerima murid', d: 'Akun dashboard mitra dibuat (login pakai email). Profilmu bisa tampil di "Cari guru terdekat", dan kamu mulai menerima tawaran murid.',
what: ['Akun dashboard mitra dibuat — login pakai email yang kamu daftarkan.', 'Profilmu bisa tampil di "Cari guru terdekat": nama depan + inisial, area kira-kira ±1 km.', 'Tawaran murid masuk; terima atau tolak tanpa sanksi.'],
prep: [] },
];
function Onboarding() {
const [cur, setCur] = useState(0);
const tabs = useRef([]);
const go = (i) => { const n = (i + ONBOARD.length) % ONBOARD.length; setCur(n); if (tabs.current[n]) tabs.current[n].focus(); };
const onKey = (e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); go(cur + 1); }
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); go(cur - 1); }
else if (e.key === 'Home') { e.preventDefault(); go(0); }
else if (e.key === 'End') { e.preventDefault(); go(ONBOARD.length - 1); }
};
const s = ONBOARD[cur];
return (
);
}
// Fallback teks bila /config tidak menjawab — sama dengan INSTRUMENTS ims-api/src/hp.js.
const INSTRUMENT_TEXT = 'gitar, gitar listrik, piano/keyboard, vokal, biola, atau drum pad';
function Requirements({ cfg, maxR }) {
const inst = cfg ? cfg.instruments.map((i) => shortInst(i.label).toLowerCase()).join(', ') : INSTRUMENT_TEXT;
return (
Apa yang kami butuhkan
Usia minimal 18 tahun.
Foto KTP dan selfie wajah jelas.
SKCK yang berlaku — boleh menyusul, wajib sebelum aktif.
1–3 referensi yang bisa dihubungi soal pengalaman mengajarmu (bukan keluarga).
Bisa mengajar minimal satu: {inst}.
Nomor WhatsApp & email aktif (untuk akun dashboard mitra).
Ketersediaan mingguan + titik tempat tinggal dan radius (maks {maxR} km).
Yang perlu kamu tahu
Mitra, bukan karyawan. Tidak ada jam kerja tetap; kamu atur ketersediaan & radius, dan boleh menolak tawaran tanpa sanksi. Standar mutu Metode IMS tetap berlaku.
Non-solicit. Selama terdaftar dan 12 bulan setelah keluar, kamu tidak boleh menawarkan, menerima, atau mengajar murid yang kamu kenal lewat IMS di luar platform. Kalau ortu menghubungi langsung, arahkan kembali ke IMS. Rincian & sanksinya ada di perjanjian.
Pembayaran hanya lewat IMS. Jangan menerima uang langsung dari murid/ortu IMS.
Data murid dijaga. Nomor ortu tidak diberikan ke guru — komunikasi lewat kanal IMS. Alamat murid dibuka setelah penugasan dikonfirmasi.
Tiap sesi: check-in GPS + foto saat datang, check-out saat selesai, isi catatan progres; ortu mengonfirmasi sesinya.
Murid anak: orang dewasa wajib ada di rumah selama sesi murid di bawah 17 tahun.
Kami tidak menjanjikan jumlah murid atau penghasilan tertentu — tergantung permintaan di areamu dan jadwal yang kamu buka.
);
}
/* "Cek kesiapanmu" — hanya state di memori (tidak disimpan ke localStorage/cookie, tidak dikirim). */
const READY = [
{ k: 'age', t: 'Usia minimal 18 tahun', req: true },
{ k: 'ktp', t: 'Foto KTP jelas, semua tulisan terbaca', req: true },
{ k: 'selfie', t: 'Bisa foto selfie wajah jelas (tanpa masker/kacamata hitam)', req: true },
{ k: 'refs', t: '1–3 referensi yang bisa dihubungi (bukan keluarga)', req: true },
{ k: 'inst', t: 'Bisa mengajar minimal satu alat musik Home Private', req: true },
{ k: 'sched', t: 'Punya jadwal mingguan yang bisa dibuka + radius yang realistis', req: true },
{ k: 'contact', t: 'WhatsApp & email aktif', req: true },
{ k: 'skck', t: 'SKCK berlaku', req: false, note: 'boleh menyusul — wajib sebelum aktif' },
];
function Readiness() {
const [on, setOn] = useState({});
const req = READY.filter((x) => x.req);
const done = req.filter((x) => on[x.k]).length;
const pct = Math.round((done / req.length) * 100);
const all = done === req.length;
return (
Cek kesiapanmu
Centang yang sudah siap. Hanya di layar kamu — tidak disimpan, tidak dikirim.
);
}
function faqItems(maxR, share) {
return [
{ q: 'Berapa honornya?', a: 'Bagi hasil ' + share + '% untuk guru dan ' + (100 - share) + '% untuk IMS, dari nilai yang dibayar murid ke IMS, dihitung per sesi yang selesai. Kalau murid membatalkan mendadak (kurang dari 3 jam sebelum sesi), kamu tetap dapat 50% honor sesi itu. Angka lengkapnya tertulis di perjanjian kemitraan.' },
{ q: 'Kapan honor dicairkan?', a: 'Honor tercatat per sesi yang dikonfirmasi ortu (otomatis terkonfirmasi bila ortu tidak merespons dalam 24 jam). Pencairan tiap 4 sesi selesai untuk murid paket, atau akhir bulan untuk murid eceran, setelah dipotong pajak sesuai ketentuan.' },
{ q: 'Apa saya karyawan IMS?', a: 'Bukan. Hubungannya kemitraan: kamu mengatur ketersediaan dan radius sendiri, dan boleh menolak tawaran tanpa sanksi. IMS menetapkan standar mutu (Metode IMS, ketepatan waktu, kode etik) agar pengalaman murid seragam.' },
{ q: 'Apa itu non-solicit?', a: 'Selama terdaftar dan 12 bulan setelah keluar, kamu tidak boleh mengambil murid yang kamu kenal lewat IMS untuk les di luar platform — termasuk menerima bayaran langsung. Ini melindungi murid, ortu, dan sesama guru. Rinciannya kamu baca sebelum tanda tangan.' },
{ q: 'Wilayahnya di mana?', a: 'Mulai dari klaster pilot Gading Serpong – BSD – Alam Sutera, dekat studio IMS. Kamu memilih radius sendiri, maksimal ' + maxR + ' km dari rumahmu.' },
{ q: 'Siapa yang menyiapkan alat musik?', a: 'Murid. Untuk piano/keyboard dipakai alat milik murid, gitar listrik dengan amp kecil. Drum kit penuh tidak dipakai untuk les di rumah — hanya drum pad / practice kit.' },
];
}
/* ─────────────────────────────────────────────
App
───────────────────────────────────────────── */
const HP_LINKS = [
{ href: 'index.html', label: 'Beranda' },
{ href: '#kenapa', label: 'Kenapa IMS' },
{ href: '#alur', label: 'Alur' },
{ href: '#syarat', label: 'Syarat' },
{ href: 'les-di-rumah.html', label: 'Les di rumah' },
];
function JadiGuruApp() {
const [hp, closeHp] = useHpConfig();
const cfg = hp.state === 'open' ? hp.cfg : null;
const maxR = cfg ? cfg.max_radius_km : 15;
const minutes = cfg ? cfg.session_minutes : 60;
const share = teacherShare(cfg);
const hasPrice = !!(cfg && cfg.prices && (Number(cfg.prices.paket) > 0 || Number(cfg.prices.eceran) > 0));
const waText = 'Halo IMS! Aku guru musik dan tertarik jadi guru mitra Home Private. Aku bisa mengajar: ';
return (
Lewati ke konten
IMS Home Private · Rekrutmen guru mitra
Ngajar musik, di dekat rumahmu.
Jadwal kamu atur. Jadi guru mitra INDIE Music School: kamu mengajar di rumah murid sekitar tempat tinggalmu,
IMS yang mencarikan murid, menyiapkan kurikulum, dan mengurus pembayarannya.