/* ============================================================
invite-main.jsx — Thiệp cưới "Thanh lịch" · responsive (web + ĐT)
Dùng lại khối chung từ invite-shared.jsx (WED, Photo, Sprig,
useCountdown, MapButton, MusicPlayer, Reveal).
============================================================ */
const { useState, useEffect, useRef } = React;
const pad2 = (n) => String(n).padStart(2, '0');
/* ---------- Ảnh thật trong folder image/ — gán theo id slot ---------- */
const PHOTOS = {
'hero-main': 'image/DSC_6096.jpg',
'band-1': 'image/L1340434.jpg',
'band-2': 'image/L1340194.jpg',
'gal-0': 'image/L1340100.jpg',
'gal-1': 'image/L1340194.jpg',
'gal-2': 'image/L1340422.jpg',
'gal-3': 'image/L1340434.jpg',
'gal-4': 'image/DSC_6096.jpg',
'gal-5': 'image/DSC_7069.jpg',
};
/* ---------- Ô ảnh (gán sẵn src, vẫn cho phép kéo-thả ghi đè) ---------- */
function ImgSlot({ id, placeholder, shape = 'rect', radius, fit = 'cover', style }) {
const props = { id, placeholder, shape, fit, style };
if (radius != null) props.radius = radius;
if (PHOTOS[id]) props.src = PHOTOS[id];
return React.createElement('image-slot', props);
}
/* ---------- Đếm ngược responsive ---------- */
function CountdownR() {
const { d, h, m, s } = window.useCountdown(window.WED.dateISO);
const items = [[d, 'Ngày'], [h, 'Giờ'], [m, 'Phút'], [s, 'Giây']];
return (
{items.map(([v, lab]) => (
{pad2(v)}
{lab}
))}
);
}
/* ---------- Dải ảnh full-bleed (điểm nhấn) + parallax chữ ---------- */
function Band({ id, lab, big, sub }) {
const bandRef = useRef(null);
const capRef = useRef(null);
// Parallax: chữ trôi nhẹ ngược chiều cuộn — ghi thẳng DOM qua rAF,
// biên độ giới hạn để không trôi quá đà.
useEffect(() => {
if (window.__reduceMotion) return;
const band = bandRef.current, cap = capRef.current;
if (!band || !cap) return;
let raf = 0;
const update = () => {
raf = 0;
const r = band.getBoundingClientRect();
const vh = window.innerHeight;
if (r.bottom < 0 || r.top > vh) return; // ngoài màn hình thì bỏ qua
// tiến trình -1..1 khi band đi qua khung nhìn
const prog = (r.top + r.height / 2 - vh / 2) / (vh / 2 + r.height / 2);
const shift = Math.max(-40, Math.min(40, prog * -40)); // giới hạn ±40px
cap.style.transform = 'translate3d(0,' + shift.toFixed(1) + 'px,0)';
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); if (raf) cancelAnimationFrame(raf); };
}, []);
return (
{lab &&
{lab}}
{big &&
{big}
}
{sub &&
{sub}}
);
}
/* ---------- Album slideshow (crossfade + auto-advance + dots) ---------- */
function GalleryR() {
const ids = ['gal-0', 'gal-1', 'gal-2', 'gal-3', 'gal-4', 'gal-5'];
const [idx, setIdx] = useState(0);
const [paused, setPaused] = useState(false);
const total = ids.length;
useEffect(() => {
if (paused) return;
const t = setInterval(() => setIdx((i) => (i + 1) % total), 5000);
return () => clearInterval(t);
}, [paused, total]);
const go = (n) => setIdx((n + total) % total);
return (
setPaused(true)}
onMouseLeave={() => setPaused(false)}>
{ids.map((id, i) => (
))}
{pad2(idx + 1)} / {pad2(total)}
{ids.map((_, i) => (
);
}
/* ---------- Nhãn + gạch mảnh ---------- */
function LabelRule({ children }) {
return (
{children}
);
}
/* ---------- Bìa thiệp ---------- */
function Cover({ open, onOpen }) {
const W = window.WED;
return (
Save the date
{W.groom}
&
{W.bride}
{W.dateLong}
);
}
/* ---------- Sổ lưu bút (lời chúc) — lưu localStorage ---------- */
function Wishes() {
const seed = [
{ name: 'Gia đình hai họ', msg: 'Chúc hai con trăm năm hạnh phúc, sắt son một đời.' },
];
const [list, setList] = useState(() => {
try { const s = JSON.parse(localStorage.getItem('wedding-wishes') || 'null'); return s && s.length ? s : seed; }
catch { return seed; }
});
const [name, setName] = useState('');
const [msg, setMsg] = useState('');
const add = (e) => {
e.preventDefault();
if (!name.trim() || !msg.trim()) return;
const next = [{ name: name.trim(), msg: msg.trim() }, ...list];
setList(next);
try { localStorage.setItem('wedding-wishes', JSON.stringify(next)); } catch {}
setName(''); setMsg('');
};
return (
{list.map((w, i) => (
))}
);
}
/* ---------- Thiệp chính ---------- */
function Invite() {
const W = window.WED;
const [opened, setOpened] = useState(false);
const [coverGone, setCoverGone] = useState(false);
const [musicSignal, setMusicSignal] = useState(0);
const progressRef = useRef(null);
// Thanh tiến trình cuộn — ghi thẳng style.width qua rAF (gộp khung hình),
// KHÔNG cập nhật React state mỗi lần cuộn ⇒ mượt, không vẽ lại ô ảnh.
useEffect(() => {
document.body.classList.add('locked');
let raf = 0;
const update = () => {
raf = 0;
const h = document.documentElement;
const max = h.scrollHeight - h.clientHeight;
const pct = max > 0 ? (h.scrollTop / max) * 100 : 0;
if (progressRef.current) progressRef.current.style.width = pct + '%';
};
const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); if (raf) cancelAnimationFrame(raf); };
}, []);
const openInvite = () => {
setOpened(true);
document.body.classList.remove('locked');
setMusicSignal((s) => s + 1);
window.scrollTo({ top: 0 });
// Gỡ bìa khỏi DOM sau khi mờ dần — chắc chắn không che nội dung
setTimeout(() => setCoverGone(true), 1150);
};
const toNext = () => {
const el = document.getElementById('after-hero');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
{!coverGone &&
}
{/* ---------- HERO — mở ra theo nhịp (cascade 0 → 680ms) ---------- */}
{W.monogram}
Trân trọng kính mời
{W.groom}
&
{W.bride}
{W.dateText}
{/* ---------- LỜI MỜI ---------- */}
“{W.quote}”
{W.quoteAuthor}
{W.invite}
{/* ---------- DẢI ẢNH 1 (điểm nhấn) ---------- */}
{/* ---------- GIA ĐÌNH ---------- */}
Nhà Trai
{W.groomParents.map((n) =>
{n}
)}
{W.groomAddress}
Nhà Gái
{W.brideParents.map((n) =>
{n}
)}
{W.brideAddress}
{/* ---------- ĐẾM NGƯỢC ---------- */}
Đếm ngược
Đến ngày chung đôi
{W.dateLong}
{/* ---------- ALBUM ---------- */}
Album
Khoảnh khắc của chúng tôi
{/* ---------- SỰ KIỆN ---------- */}
Sự kiện
{[W.ceremony, W.party].map((e) => (
{e.label}
{e.time}
{e.note} · {W.weekday}, {W.day}/{W.month}/{W.year}
))}
{W.venueFull}
{W.hall}
{W.address}
{/* ---------- BẢN ĐỒ ---------- */}
{/* ---------- DẢI ẢNH 2 (điểm nhấn) ---------- */}
{/* ---------- SỔ LƯU BÚT ---------- */}
Sổ lưu bút
Lời chúc yêu thương
{/* ---------- KẾT ---------- */}
{W.closing}
{W.groom} & {W.bride}
{W.dateText}
);
}
ReactDOM.createRoot(document.getElementById('root')).render();