/* @jsx React.createElement */ /* ============================================================ INDIE Music Shop — indiemusic.id/shop.html React 18 UMD + Babel standalone. Tanpa build step, sama seperti site.jsx. Semua panggilan backend lewat same-origin api/bridge.php → :3022. ============================================================ */ const { useState, useEffect, useMemo, useRef, useCallback } = React; /* ───────────────────────────────────────────── Config ───────────────────────────────────────────── */ const BRIDGE = 'api/bridge.php'; const CART_KEY = 'ims_shop_cart_v1'; 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; } // Cermin daftar di routes/checkout.js — HANYA untuk estimasi tampilan. // Ongkir final tetap dihitung server. const JABODETABEK_KEYWORDS = [ 'jakarta', 'bogor', 'depok', 'tangerang', 'bekasi', 'serpong', 'cibubur', 'ciputat', 'pamulang', 'bintaro', 'cileungsi' ]; const SHIPPING_EST = { jabodetabek: 20000, luar_jabodetabek: 50000 }; function zoneOf(city) { const c = String(city || '').toLowerCase(); return JABODETABEK_KEYWORDS.some(k => c.includes(k)) ? 'jabodetabek' : 'luar_jabodetabek'; } const rupiah = (n) => 'Rp' + Number(n || 0).toLocaleString('id-ID'); const STATUS_LABEL = { pending_payment: { label: 'Menunggu pembayaran', bg: '#FBF1D9', fg: '#8A6200' }, paid: { label: 'Sudah dibayar', bg: '#E5F4EC', fg: '#1F6B42' }, processing: { label: 'Sedang disiapkan', bg: '#E0F4FD', fg: '#0A6B92' }, shipped: { label: 'Dikirim', bg: '#E6EEF5', fg: '#00417F' }, delivered: { label: 'Sampai tujuan', bg: '#E5F4EC', fg: '#1F6B42' }, cancelled: { label: 'Dibatalkan', bg: '#FBEEEB', fg: '#A52416' }, }; const statusInfo = (s) => STATUS_LABEL[s] || { label: s || '—', bg: 'var(--paper-2)', fg: 'var(--txt-3)' }; /* ───────────────────────────────────────────── Ikon inline (tanpa dependensi eksternal) ───────────────────────────────────────────── */ function Ico({ d, size = 20, fill = 'none', stroke = 'currentColor', sw = 2, children }) { return ( {children || } ); } const IcoCart = (p) => ; const IcoPlus = (p) => ; const IcoX = (p) => ; const IcoTrash = (p) => ; const IcoCheck = (p) => ; const IcoBack = (p) => ; const IcoSearch= (p) => ; const IcoAlert = (p) => ; const IcoTruck = (p) => ; /* ───────────────────────────────────────────── Hash routing ringan: #/rute?query ───────────────────────────────────────────── */ function parseHash() { const raw = (location.hash || '').replace(/^#\/?/, ''); const [path, qs] = raw.split('?'); const params = {}; new URLSearchParams(qs || '').forEach((v, k) => { params[k] = v; }); return { path: path || '', params }; } function useHashRoute() { const [route, setRoute] = useState(parseHash); useEffect(() => { const on = () => { setRoute(parseHash()); window.scrollTo({ top: 0, behavior: 'auto' }); }; window.addEventListener('hashchange', on); return () => window.removeEventListener('hashchange', on); }, []); return route; } const go = (to) => { location.hash = to; }; /* ───────────────────────────────────────────── Keranjang — localStorage { product_id: qty } ───────────────────────────────────────────── */ function loadCart() { try { const raw = JSON.parse(localStorage.getItem(CART_KEY) || '{}'); const out = {}; Object.keys(raw).forEach(k => { const q = parseInt(raw[k], 10); if (Number.isFinite(q) && q > 0) out[k] = q; }); return out; } catch (e) { return {}; } } function useCart() { const [cart, setCart] = useState(loadCart); useEffect(() => { try { localStorage.setItem(CART_KEY, JSON.stringify(cart)); } catch (e) {} }, [cart]); // Sinkron antar tab. useEffect(() => { const on = (e) => { if (e.key === CART_KEY) setCart(loadCart()); }; window.addEventListener('storage', on); return () => window.removeEventListener('storage', on); }, []); const add = useCallback((id, qty = 1, max = Infinity) => { setCart(c => ({ ...c, [id]: Math.min(max, (c[id] || 0) + qty) })); }, []); const setQty = useCallback((id, qty, max = Infinity) => { setCart(c => { const next = { ...c }; const q = Math.min(max, Math.max(0, qty)); if (q <= 0) delete next[id]; else next[id] = q; return next; }); }, []); const remove = useCallback((id) => setCart(c => { const n = { ...c }; delete n[id]; return n; }), []); const clear = useCallback(() => setCart({}), []); return { cart, add, setQty, remove, clear }; } /* ───────────────────────────────────────────── Nav + Footer — meminjam kelas dari styles.css ───────────────────────────────────────────── */ function ShopNav({ count, onOpenCart }) { return (
INDIE Music INDIE Music Shop
Tanya admin
); } function ShopFooter() { return ( ); } /* ───────────────────────────────────────────── Kartu produk ───────────────────────────────────────────── */ function firstImage(p) { const imgs = Array.isArray(p.images) ? p.images : []; const src = imgs.find(x => typeof x === 'string' && x.trim()); return src ? src.trim() : null; } function ProductCard({ p, inCart, onOpen, onAdd }) { const img = firstImage(p); const stock = Number(p.stock) || 0; const out = stock <= 0; const full = inCart >= stock; return (
{p.category &&
{p.category}
}

{p.name}

{p.description &&

{p.description}

}
{rupiah(p.price)}
); } /* ───────────────────────────────────────────── Modal detail produk ───────────────────────────────────────────── */ function ProductModal({ p, inCart, onClose, onAdd }) { const [qty, setQty] = useState(1); const [idx, setIdx] = useState(0); const imgs = (Array.isArray(p.images) ? p.images : []).filter(x => typeof x === 'string' && x.trim()); const stock = Number(p.stock) || 0; const remaining = Math.max(0, stock - inCart); useEffect(() => { const on = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', on); document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', on); document.body.style.overflow = ''; }; }, [onClose]); useEffect(() => { setQty(remaining > 0 ? 1 : 0); }, [p.id]); return (
e.stopPropagation()}>
{imgs.length ? {p.name} :
{(p.name || '?').slice(0, 2)}
} {imgs.length > 1 && (
{imgs.map((src, i) => ( ))}
)}
{p.category &&
{p.category}
}

{p.name}

{rupiah(p.price)}
{p.description &&

{p.description}

}
{stock > 0 ? Stok: {stock} : Stok habis} {p.weight_gram ? Berat: {p.weight_gram} g : null}
{remaining > 0 ? (
{qty}
) : (
{stock <= 0 ? 'Produk ini sedang habis. Hubungi admin untuk info restock.' : 'Semua stok yang tersedia sudah ada di keranjangmu.'}
)}
); } /* ───────────────────────────────────────────── Drawer keranjang ───────────────────────────────────────────── */ function CartDrawer({ open, lines, subtotal, onClose, onSetQty, onRemove, onCheckout }) { useEffect(() => { if (!open) return; const on = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', on); document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', on); document.body.style.overflow = ''; }; }, [open, onClose]); if (!open) return null; const hasIssue = lines.some(l => l.issue); return ( <>
); } /* ───────────────────────────────────────────── Halaman katalog ───────────────────────────────────────────── */ function CatalogView({ products, loading, error, cart, onOpen, onAdd, onRetry }) { const [cat, setCat] = useState(''); const [q, setQ] = useState(''); const categories = useMemo(() => { const s = []; products.forEach(p => { const c = p.category || 'Lainnya'; if (!s.includes(c)) s.push(c); }); return s; }, [products]); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); return products.filter(p => { if (cat && (p.category || 'Lainnya') !== cat) return false; if (needle) { const hay = ((p.name || '') + ' ' + (p.description || '') + ' ' + (p.category || '')).toLowerCase(); if (!hay.includes(needle)) return false; } return true; }); }, [products, cat, q]); const grouped = useMemo(() => { const map = new Map(); filtered.forEach(p => { const c = p.category || 'Lainnya'; if (!map.has(c)) map.set(c, []); map.get(c).push(p); }); return Array.from(map.entries()); }, [filtered]); return ( <>
INDIE Music · Shop

Perlengkapan buat
yang serius main.

Merch, aksesori, dan perlengkapan musik pilihan langsung dari studio kami di Gading Serpong. Dikirim ke seluruh Indonesia — bayar aman lewat Xendit.

{!loading && !error && products.length > 0 && ( <>
{categories.map(c => ( ))}
setQ(e.target.value)} placeholder="Cari produk…" aria-label="Cari produk" style={{ width: '100%', padding: '12px 14px 12px 42px', fontFamily: 'var(--f-body)', fontSize: 14, border: '1px solid var(--line)', borderRadius: 999, background: '#fff', outline: 'none', }} />
)}
{loading && (
{[0, 1, 2, 3, 4, 5, 6, 7].map(i =>
)}
)} {!loading && error && (
Katalog gagal dimuat.
{error}
)} {!loading && !error && products.length === 0 && (
Belum ada produk yang tayang. Cek lagi sebentar lagi, ya.
)} {!loading && !error && products.length > 0 && filtered.length === 0 && (
Tidak ada produk yang cocok dengan pencarianmu.
)} {grouped.map(([c, list]) => (
{c}
{list.length} produk
{list.map(p => ( ))}
))}
); } /* ───────────────────────────────────────────── Halaman checkout ───────────────────────────────────────────── */ const EMPTY_FORM = { name: '', phone: '', email: '', street: '', city: '', province: '', postal_code: '', notes: '' }; function CheckoutView({ lines, subtotal, onDone }) { const [f, setF] = useState(EMPTY_FORM); const [errors, setErrors] = useState({}); const [submitting, setSubmitting] = useState(false); const [apiErr, setApiErr] = useState(null); const set = (k) => (e) => setF(prev => ({ ...prev, [k]: e.target.value })); const zone = zoneOf(f.city); const shippingEst = f.city.trim() ? SHIPPING_EST[zone] : null; const totalEst = subtotal + (shippingEst || 0); const hasIssue = lines.some(l => l.issue); const validate = () => { const e = {}; if (!f.name.trim()) e.name = 'Nama wajib diisi'; const digits = f.phone.replace(/[^0-9]/g, ''); if (!digits) e.phone = 'Nomor WhatsApp wajib diisi'; else if (digits.length < 9) e.phone = 'Nomor WhatsApp tidak valid'; if (!f.email.trim()) e.email = 'Email wajib diisi'; else if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(f.email.trim())) e.email = 'Format email tidak valid'; if (!f.street.trim()) e.street = 'Alamat lengkap wajib diisi'; if (!f.city.trim()) e.city = 'Kota wajib diisi'; setErrors(e); return Object.keys(e).length === 0; }; const submit = async (ev) => { ev.preventDefault(); setApiErr(null); if (!validate()) return; if (!lines.length) { setApiErr('Keranjang kosong.'); return; } setSubmitting(true); try { const r = await fetch(BRIDGE + '/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer: { name: f.name.trim(), email: f.email.trim(), phone: f.phone.trim() }, shipping: { street: f.street.trim(), city: f.city.trim(), province: f.province.trim(), postal_code: f.postal_code.trim(), notes: f.notes.trim(), }, // Hanya id + qty: harga & nama ditentukan server dari DB. items: lines.map(l => ({ product_id: l.product_id, qty: l.qty })), }), }); const j = await r.json().catch(() => ({ ok: false, error: 'Respons server tidak valid' })); if (!j.ok) throw new Error(j.error || 'Checkout gagal'); if (!j.payment_url) throw new Error('URL pembayaran tidak diterima'); onDone(j.order_number); window.location.href = j.payment_url; } catch (err) { setApiErr(err.message || String(err)); setSubmitting(false); } }; if (!lines.length) { return (

Keranjang kosong

Belum ada produk yang bisa di-checkout.

Lihat katalog
); } return (
Kembali ke katalog
Langkah terakhir

Checkout

{/* ── Kiri: data pembeli ── */}

Data pembeli

{errors.name &&
{errors.name}
}
{errors.phone ?
{errors.phone}
:
Dipakai admin untuk konfirmasi order.
}
{errors.email ?
{errors.email}
:
Invoice pembayaran dikirim ke sini.
}

Alamat pengiriman