/* @jsx React.createElement */
/* ============================================================
INDIE Music School & Studio — single-file site
React 18 UMD + Babel standalone. No build step.
All components + DEFAULT_CONTENT + Supabase CMS loader.
============================================================ */
const { useState, useEffect, useRef, useCallback } = React;
/* ─────────────────────────────────────────────
Config
───────────────────────────────────────────── */
const SUPABASE_URL = 'https://indie.supabase.co';
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Indsc3pqdnZveXltd2dwbXRnd2V4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQ1ODg5OTcsImV4cCI6MjA5MDE2NDk5N30.5emUfc2jlLxutLhtGZFD0Rq3MOUl4JAcU3Fd5RydjRU';
const CONTENT_TABLE = 'ims_website_content';
const FETCH_TIMEOUT = 3000;
const WA_NUMBER = '628998267999';
const WA_DISPLAY = '+62 899-8267-999';
function waLink(text) {
const base = 'https://wa.me/' + WA_NUMBER;
return text ? base + '?text=' + encodeURIComponent(text) : base;
}
/* ─────────────────────────────────────────────
DEFAULT_CONTENT — the site renders from this instantly.
CMS rows deep-merge over these values.
───────────────────────────────────────────── */
const DEFAULT_CONTENT = {
hero: {
eyebrow: 'INDIE Music · School & Studio',
title: 'Happiness starts here.',
subtitle: 'Sekolah musik anti-mainstream di Gading Serpong. Studio yang estetik, guru yang asik, dan cara belajar yang interaktif — bikin musik nggak lagi kayak beban, tapi jadi happiness yang dipancarkan tiap minggu.',
cta_primary: { label: 'Coba kelas gratis', url: waLink('Halo IMS! Aku mau coba kelas gratis dong.') },
cta_secondary: { label: 'Lihat metodenya', url: '#method' },
video_url: '',
slides: [
{
image: 'assets/photos/hero-band.jpg',
eyebrow: 'INDIE Music · School & Studio',
title: 'Happiness starts here.',
subtitle: 'Sekolah musik anti-mainstream di Gading Serpong. Studio yang estetik, guru yang asik, dan cara belajar yang interaktif — bikin musik nggak lagi kayak beban.',
typing: true,
},
{
image: 'assets/photos/hero-drum-kid.jpg',
eyebrow: 'Kurikulum anti-mainstream',
title: 'Main musik, bukan dihafalin teori.',
subtitle: 'Dari minggu pertama kamu udah main lagu beneran — teori nyusul sambil jalan, bukan jadi gerbang.',
typing: true,
},
{
image: 'assets/photos/hero-piano-hands.jpg',
eyebrow: 'Studio estetik · Gading Serpong',
title: 'Belajar di tempat yang bikin betah.',
subtitle: 'Ruang kelas & studio recording standar pro. Datang buat les, pulang bawa vibe.',
typing: false,
},
],
images: [
'assets/photos/hero-band.jpg',
'assets/photos/hero-drum-kid.jpg',
'assets/photos/hero-piano-hands.jpg',
],
},
marquee: {
items: [
'Guitar', 'Piano', 'Drum', 'Bass', 'Vocal',
'Anak & Dewasa', 'Private · Group · In-Home',
'Pre-Beginner → Performance 3', '6 Skill Dimensions',
'59 Stamps', 'Parent Dashboard', 'Studio Recording & Rehearsal',
],
},
stats: {
items: [
{ value: '5', suffix: '', label: 'Instrumen' },
{ value: '6', suffix: '', label: 'Level journey' },
{ value: '59', suffix: '', label: 'Stamp dikoleksi' },
{ value: '92', suffix: '%', label: 'Lanjut setelah bulan 3' },
],
},
instruments: {
title: 'Lima instrumen. Satu tempat buat jatuh cinta sama musik.',
intro: 'Mau mulai dari mana? Pilih instrumen kamu — atau anak kamu. Semua diajar dengan lagu beneran sejak minggu pertama.',
items: [
{ key: 'guitar', name: 'Guitar', desc: 'Dari open chord pertama sampai fingerstyle solo. Akustik, elektrik, klasik — pilih gitar kamu.', image: 'assets/photos/lesson-guitar.jpg', wa_text: 'Halo IMS! Aku mau coba kelas gitar.' },
{ key: 'piano', name: 'Piano', desc: 'Koordinasi dua tangan, pengenalan chord & scale, teori dasar. Pop dan lainnya, sesuai karakter kamu.', image: 'assets/photos/lesson-piano.jpg', wa_text: 'Halo IMS! Aku mau coba kelas piano.' },
{ key: 'drum', name: 'Drum', desc: 'Koordinasi tangan dan kaki, groove basic 4/4, drum fill pertama kamu. Rock dan Pop.', image: 'assets/photos/hero-drum-kid.jpg', wa_text: 'Halo IMS! Aku mau coba kelas drum.' },
{ key: 'bass', name: 'Bass', desc: 'Feel, groove, dan fondasi dalam sebuah lagu. Petik dua jari yang stabil, lock sama drum.', image: 'assets/photos/lesson-bass.jpg', wa_text: 'Halo IMS! Aku mau coba kelas bass.' },
{ key: 'vocal', name: 'Vocal', desc: 'Cari suara asli kamu. Napas, pitch, tone — plus pede pakainya. Dari matching pitch sampai lagu utuh.', image: 'assets/photos/lesson-vocal.jpg', wa_text: 'Halo IMS! Aku mau coba kelas vokal.' },
],
},
method: {
title: 'Progress yang beneran keliatan — tanpa bikin kayak ujian.',
intro: 'Sekolah lain biasanya cuma bilang "good job" terus selesai. Kita bikin perjalanan kamu kelihatan, dari chord pertama sampai naik panggung.',
steps: [
{ title: 'Coba dulu, gratis', desc: 'Session pertama gratis buat murid baru. Nggak perlu kartu kredit, nggak perlu komitmen. Dateng, main, rasain vibe-nya.' },
{ title: 'Ketemu guru & level kamu', desc: 'Kita mulai dari posisi kamu sekarang — Pre-Beginner sampai Performance. Nggak ada yang buru-buru, nggak ada yang ketinggalan.' },
{ title: 'Level + Radar + Badges', desc: 'Tiga lensa jujur: Level Journey (6 milestone), Skill Radar (6 dimensi di-score tiap session), dan Milestone Badges yang di-unlock kalau kamu beneran udah bisa.' },
{ title: 'Naik panggung & masuk studio', desc: 'Di Performance 1, kamu dapet studio recording session beneran — bukan add-on berbayar. Take-nya kamu bawa pulang.' },
],
},
teachers: {
title: 'Belajar bareng orangnya langsung.',
intro: 'Di IMS, kamu nggak diajar sama "instruktur generasi ke-tiga" yang baru kerja minggu lalu. Kamu diajar sama orang yang bikin sekolahnya — kurikulum, filosofi, dan vibe-nya.',
items: [
{
name: 'Andre Putra',
role: 'Co-Founder & Main Teacher',
instrument: 'Guitar',
bio: 'Andre udah main gitar sejak kelas 6 SD, mulai ngajar sejak SMA, dan akhirnya mendirikan INDIE Music School karena capek lihat banyak anak belajar musik tapi malah nggak enjoy. Filosofinya simple: musik seharusnya jadi tempat buat bahagia, berekspresi, dan berkembang — bukan bikin stress.',
photo_url: '',
},
],
},
gallery: {
title: 'Intip dulu tempatnya.',
images: [
{ src: 'assets/photos/group-kids.jpg', caption: 'Kelas grup anak — seru, nggak nyeremin.' },
{ src: 'assets/photos/recital-stage.jpg', caption: 'Recital & panggung — momen naik level.' },
{ src: 'assets/photos/studio-gear.jpg', caption: 'Studio gear — dipakai murid & musisi luar.' },
{ src: 'assets/photos/lesson-piano.jpg', caption: 'Ruang kelas piano yang hangat.' },
{ src: 'assets/photos/hero-band.jpg', caption: 'Live room — rekam single yang siap rilis.' },
{ src: 'assets/photos/lesson-guitar.jpg', caption: 'One-on-one, langsung sama founder-nya.' },
],
},
testimonials: {
title: 'Kata mereka yang udah main bareng kita.',
items: [
{ quote: 'Anak gue dulu suka minta skip les piano. Sekarang malah minta berangkat lebih cepet biar bisa ngobrol dulu sama gurunya sebelum kelas mulai. Itu udah review-nya.', name: 'Rina P.', meta: 'Orang tua Kayla, 9 thn · Piano · 8 bulan di IMS' },
{ quote: 'Umur 34 baru mulai belajar gitar dari nol total. Ternyata nggak seseram itu — Pre-Beginner emang didesain buat orang kayak aku. Sekarang udah bisa ngiringin lagu sendiri.', name: 'Danang S.', meta: 'Murid dewasa · Guitar · Private' },
{ quote: 'Yang bikin beda itu progress-nya keliatan. Tiap minggu ada catatan, ada stamp, radar-nya nambah. Jadi tahu uang les nggak ke mana-mana.', name: 'Melisa T.', meta: 'Orang tua Raka, 11 thn · Drum' },
],
},
pricing: {
title: 'Satu kurikulum, tiga cara belajar.',
intro: 'Kurikulum sama, ritme beda. Semua harga per bulan (4 session). Sepanjang tahun kamu boleh kombinasi format.',
plans: [
{ name: 'Group', price: 'Rp 700rb', unit: '/ bulan · 4 session', features: ['Maks 4 murid per kelas', 'Lebih hemat, lebih seru', 'Skill Radar + Stamp card', 'Parent dashboard'], highlight: false },
{ name: 'Private', price: 'Rp 1,1jt', unit: '/ bulan · 4 session', features: ['1-on-1 langsung sama guru', 'Jadwal fleksibel', 'Skill Radar + Milestone Badges', 'Studio session di Performance 1', 'Parent dashboard'], highlight: true },
{ name: 'In-Home Private', price: 'Rp 1,5jt', unit: '/ bulan · 4 session', features: ['Guru datang ke rumah kamu', 'Greater Jakarta only', 'Semua benefit Private', 'Nggak perlu jalan macet'], highlight: false },
],
},
faq: {
title: 'Pertanyaan yang sering masuk ke WA kita.',
items: [
{ q: 'Berapa lama sampai aku bisa main satu lagu utuh?', a: 'Biasanya minggu ke-3 udah bisa. Lagu beneran, yang orang kenal, yang "bisa direkam pakai HP". Belum Chopin sih, tapi udah musik.' },
{ q: 'Kalau aku/anakku mau ganti instrumen, bisa?', a: 'Bebas ganti kapan aja. Stamp card sama Skill Radar yang umum (rhythm, ear, theory) tetep nyangkut. Yang spesifik per instrumen reset.' },
{ q: 'Ada prep ujian musik (ABRSM, RSL) nggak?', a: 'Ada. Kasih tau pas daftar — kita slot-in ke kurikulum yang sesuai. Andre handle prep gitar; piano dikejar bareng kurikulum standar.' },
{ q: 'Orang dewasa beneran bisa mulai dari nol?', a: 'Setengah murid dewasa kita mulai dari nol total. Pre-Beginner emang didesain buat itu. Nggak ada yang buru-buru.' },
{ q: 'Policy cancel / reschedule gimana?', a: 'Reschedule maksimal 6 jam sebelum kelas — gratis. Lebih dari itu, kehitung. Semua status ke-track di dashboard.' },
{ q: 'Studio bisa di-rent buat non-murid IMS?', a: 'Bisa. Booking by hour, dengan atau tanpa engineer. WA aja buat cek slot kosong. Murid IMS dapet prioritas + rate khusus.' },
],
},
cta: {
title: 'Mampir dulu. Pulang udah senyum.',
subtitle: 'Booking trial 90 detik. Kita match-in kamu ke guru, hold slot kamu, dan siapin instrumen yang udah ditune dari tadi.',
button: { label: 'Coba kelas gratis via WhatsApp', url: waLink('Halo IMS! Aku mau booking kelas trial gratis.') },
},
contact: {
address: 'Ruko Neo Arcade Blok A33, Gading Serpong, Tangerang, Banten 15334',
phone: '+628998267999',
wa: waLink('Halo IMS!'),
instagram: 'https://www.instagram.com/indiemusic.id',
maps_url: 'https://maps.google.com/?q=-6.243819059545993,106.62397551047178',
hours: 'Buka tiap hari · 10.00 – 21.00',
},
};
/* ─────────────────────────────────────────────
Deep merge — CMS data over defaults.
Objects merge recursively; arrays & scalars replace.
───────────────────────────────────────────── */
function isPlainObject(v) {
return v && typeof v === 'object' && !Array.isArray(v);
}
function deepMerge(base, over) {
if (!isPlainObject(base) || !isPlainObject(over)) {
return over === undefined ? base : over;
}
const out = Object.assign({}, base);
for (const k of Object.keys(over)) {
if (isPlainObject(base[k]) && isPlainObject(over[k])) {
out[k] = deepMerge(base[k], over[k]);
} else if (over[k] !== undefined) {
out[k] = over[k];
}
}
return out;
}
/* ─────────────────────────────────────────────
Content hook — render defaults immediately,
re-render when Supabase resolves. Any failure → defaults.
───────────────────────────────────────────── */
function useContent() {
const [content, setContent] = useState(DEFAULT_CONTENT);
useEffect(() => {
let alive = true;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
const url = SUPABASE_URL + '/rest/v1/' + CONTENT_TABLE + '?select=section,data';
fetch(url, {
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: 'Bearer ' + SUPABASE_ANON_KEY,
},
signal: controller.signal,
})
.then(r => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))))
.then(rows => {
if (!alive || !Array.isArray(rows)) return;
const merged = Object.assign({}, DEFAULT_CONTENT);
for (const row of rows) {
if (!row || !row.section) continue;
const base = DEFAULT_CONTENT[row.section] || {};
merged[row.section] = deepMerge(base, row.data || {});
}
setContent(merged);
})
.catch(() => { /* silent → defaults stay */ })
.finally(() => clearTimeout(timer));
return () => { alive = false; clearTimeout(timer); controller.abort(); };
}, []);
return content;
}
/* ─────────────────────────────────────────────
Reveal-on-scroll — IntersectionObserver, reduced-motion safe.
Returns a ref; adds .is-in when element enters viewport.
───────────────────────────────────────────── */
const FORCE_MOTION = typeof window !== 'undefined'
&& /[?&]motion=1/.test(window.location.search);
const PREFERS_REDUCED = !FORCE_MOTION
&& typeof window !== 'undefined'
&& window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// ?motion=1 juga menonaktifkan blok CSS reduced-motion
if (FORCE_MOTION && typeof document !== 'undefined') {
document.documentElement.classList.add('force-motion');
}
function useReveal(options) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (PREFERS_REDUCED || !('IntersectionObserver' in window)) {
el.classList.add('is-in');
return;
}
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
el.classList.add('is-in');
io.unobserve(el);
}
});
}, Object.assign({ threshold: 0.15, rootMargin: '0px 0px -8% 0px' }, options));
io.observe(el);
return () => io.disconnect();
}, []);
return ref;
}
function Reveal({ children, delay = 0, className = '', tag = 'div', style = {}, ...rest }) {
const ref = useReveal();
const Tag = tag;
return (
{cur.subtitle}
{cur.typing && !PREFERS_REDUCED ? (
<>
{cur.title}
{typed}
>
) : cur.title}
{data.intro}
{it.desc}
Coba kelas {it.name.toLowerCase()} →{data.intro}
{st.desc}
{data.intro}
{t.bio}
“{t.quote}”
{data.intro}
Session pertama gratis buat murid baru. Bayar setelah trial, lewat Xendit.
Selain ini, WA aja langsung. {WA_DISPLAY}.
Tanya via WhatsApp{data.subtitle}