/* @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 (
);
}
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 (
onOpen(p)} aria-label={'Lihat detail ' + p.name}>
{img
?
: {(p.name || '?').slice(0, 2)}
}
{out
? Stok habis
: stock <= 3 ? Sisa {stock} : null}
{p.category &&
{p.category}
}
{p.name}
{p.description &&
{p.description}
}
{rupiah(p.price)}
onAdd(p)}
>
);
}
/* ─────────────────────────────────────────────
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 || '?').slice(0, 2)}
}
{imgs.length > 1 && (
{imgs.map((src, i) => (
setIdx(i)} aria-label={'Gambar ' + (i + 1)}>
))}
)}
{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 ? (
setQty(q => Math.max(1, q - 1))} disabled={qty <= 1} aria-label="Kurangi">−
{qty}
setQty(q => Math.min(remaining, q + 1))} disabled={qty >= remaining} aria-label="Tambah">+
{ onAdd(p, qty); onClose(); }}>
Tambah ke keranjang
) : (
{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 (
<>
{lines.length === 0 ? (
Keranjangmu masih kosong.
Lihat katalog
) : lines.map(l => (
{l.image
?
:
{(l.name || '?').slice(0, 2)}
}
{l.name}
{rupiah(l.unit_price)} × {l.qty} = {rupiah(l.subtotal)}
{l.issue &&
{l.issue}
}
onSetQty(l.product_id, l.qty - 1, l.stock)} aria-label="Kurangi">−
{l.qty}
onSetQty(l.product_id, l.qty + 1, l.stock)} disabled={l.qty >= l.stock} aria-label="Tambah">+
onRemove(l.product_id)} aria-label={'Hapus ' + l.name}>
))}
{lines.length > 0 && (
Subtotal {rupiah(subtotal)}
Ongkir dihitung saat checkout
Total sementara {rupiah(subtotal)}
Lanjut ke checkout
{hasIssue &&
Perbaiki item bermasalah dulu sebelum checkout.
}
)}
>
);
}
/* ─────────────────────────────────────────────
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 && (
<>
setCat('')}>
Semua
{categories.map(c => (
setCat(c)}>{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}
Coba lagi
)}
{!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]) => (
))}
>
);
}
/* ─────────────────────────────────────────────
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.
);
}
return (
);
}
/* ─────────────────────────────────────────────
Detail order (dipakai order-success & cek-order)
───────────────────────────────────────────── */
function OrderDetail({ order, items }) {
const si = statusInfo(order.status);
const addr = order.shipping_address || {};
return (
<>
{si.label}
{order.order_number}
{(items || []).map((it, i) => (
{it.product_name} × {it.qty}
{rupiah(it.subtotal)}
))}
Subtotal {rupiah(order.subtotal)}
Ongkir {order.shipping_zone ? '(' + order.shipping_zone.replace('_', ' ') + ')' : ''} {rupiah(order.shipping_cost)}
Total {rupiah(order.total)}
{(addr.street || addr.city) && (
Dikirim ke
{order.customer_name}
{addr.street}
{[addr.city, addr.province, addr.postal_code].filter(Boolean).join(', ')}
)}
>
);
}
function useOrder(orderNumber) {
const [state, setState] = useState({ loading: !!orderNumber, order: null, items: [], error: null });
const load = useCallback(async (num) => {
if (!num) return;
setState(s => ({ ...s, loading: true, error: null }));
try {
const r = await fetch(BRIDGE + '/order/' + encodeURIComponent(num));
const j = await r.json().catch(() => ({ ok: false, error: 'Respons tidak valid' }));
if (!j.ok) throw new Error(j.error || 'Order tidak ditemukan');
setState({ loading: false, order: j.order, items: j.items || [], error: null });
} catch (err) {
setState({ loading: false, order: null, items: [], error: err.message || String(err) });
}
}, []);
useEffect(() => { if (orderNumber) load(orderNumber); }, [orderNumber, load]);
return { ...state, load };
}
function OrderSuccessView({ orderNumber }) {
const { loading, order, items, error } = useOrder(orderNumber);
const paid = order && order.status !== 'pending_payment' && order.status !== 'cancelled';
return (
{loading ? (
<>
>
) : error ? (
<>
Order tidak ditemukan
{error}
>
) : (
<>
{paid ? : }
{paid ? 'Terima kasih!' : 'Menunggu pembayaran'}
{paid
? 'Pembayaranmu sudah kami terima. Admin akan menghubungi kamu lewat WhatsApp untuk proses pengiriman.'
: 'Ordermu sudah tercatat, tapi pembayaran belum masuk. Selesaikan pembayaran lewat tautan invoice, ya.'}
>
)}
);
}
function OrderFailedView({ orderNumber }) {
return (
Pembayaran belum selesai
Pembayaranmu dibatalkan atau kedaluwarsa. Tenang, keranjangmu masih tersimpan —
kamu bisa mengulang checkout kapan saja.
{orderNumber &&
{orderNumber}
}
);
}
function CekOrderView({ initial }) {
const [num, setNum] = useState(initial || '');
const [query, setQuery] = useState(initial || '');
const { loading, order, items, error } = useOrder(query);
const submit = (e) => { e.preventDefault(); setQuery(num.trim().toUpperCase()); };
return (
Lacak pesanan
Cek status order
Masukkan nomor order yang kamu terima saat checkout.
setNum(e.target.value)}
placeholder="MSHOP-20260723-XXXX" aria-label="Nomor order"
style={{
flex: 1, minWidth: 230, padding: '13px 16px', fontFamily: 'var(--f-body)', fontSize: 14,
border: '1px solid var(--line)', borderRadius: 12, background: '#fff', outline: 'none',
}}
/>
{loading ? 'Mencari…' : 'Cek'}
{query && error && (
{error}
)}
{order && !error && (
<>
>
)}
);
}
/* ─────────────────────────────────────────────
Root
───────────────────────────────────────────── */
function ShopApp() {
const route = useHashRoute();
const { cart, add, setQty, remove, clear } = useCart();
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [modal, setModal] = useState(null);
const [cartOpen, setCartOpen] = useState(false);
const [flash, setFlash] = useState(null);
const loadCatalog = useCallback(async () => {
setLoading(true); setError(null);
try {
const r = await fetch(BRIDGE + '/catalog/products');
const j = await r.json().catch(() => ({ ok: false, error: 'Respons tidak valid' }));
if (!j.ok) throw new Error(j.error || 'Gagal memuat katalog');
setProducts(Array.isArray(j.data) ? j.data : []);
} catch (err) {
setError(err.message || String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadCatalog(); }, [loadCatalog]);
const byId = useMemo(() => new Map(products.map(p => [p.id, p])), [products]);
// Baris keranjang = irisan cart × katalog. Produk yang hilang/habis ditandai
// supaya pembeli tidak baru tahu saat checkout ditolak server.
const lines = useMemo(() => {
return Object.keys(cart).map(id => {
const p = byId.get(id);
const qty = cart[id];
if (!p) {
return { product_id: id, name: 'Produk tidak tersedia', unit_price: 0, qty, subtotal: 0, stock: 0, image: null, issue: 'Produk sudah tidak dijual — hapus dari keranjang.' };
}
const stock = Number(p.stock) || 0;
const unit = Number(p.price) || 0;
let issue = null;
if (stock <= 0) issue = 'Stok habis — hapus dari keranjang.';
else if (qty > stock) issue = `Stok tinggal ${stock}. Kurangi jumlahnya.`;
return {
product_id: id, name: p.name, unit_price: unit, qty,
subtotal: unit * qty, stock, image: firstImage(p), issue,
};
});
}, [cart, byId]);
const subtotal = useMemo(() => lines.reduce((s, l) => s + l.subtotal, 0), [lines]);
const count = useMemo(() => lines.reduce((s, l) => s + l.qty, 0), [lines]);
const handleAdd = useCallback((p, qty = 1) => {
const stock = Number(p.stock) || 0;
if (stock <= 0) return;
add(p.id, qty, stock);
setFlash(`${p.name} masuk keranjang`);
setTimeout(() => setFlash(null), 2200);
}, [add]);
const onCheckout = () => { setCartOpen(false); go('/checkout'); };
const onCheckoutDone = (orderNumber) => { clear(); };
const path = route.path;
return (
<>
setCartOpen(true)} />
{path === 'checkout' ? (
) : path === 'order-success' ? (
) : path === 'order-failed' ? (
) : path === 'cek-order' ? (
) : (
)}
{modal && (
setModal(null)}
onAdd={handleAdd}
/>
)}
setCartOpen(false)}
onSetQty={setQty} onRemove={remove} onCheckout={onCheckout}
/>
{flash && (
{flash}
)}
>
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );