/* ============================================================ invite-shared.jsx — Dữ liệu + khối dùng chung cho 3 phương án Xuất ra window ở cuối file để các file khác dùng được. ============================================================ */ // ---------- NỘI DUNG THIỆP (sửa tại đây) ---------- const WED = { groom: 'Phi Vũ', bride: 'Hồng Sương', groomFull: 'Nguyễn Phi Vũ', brideFull: 'Nguyễn Thị Hồng Sương', groomParents: ['Ông Nguyễn Hoàng Dũng', 'Bà Nguyễn Thị Dẫn'], brideParents: ['Ông Nguyễn Văn Thưởng', 'Bà Đặng Thị Hường'], groomAddress: 'Tổ 13, Ấp Bầu 2, X. Bình Mỹ, TP. HCM', brideAddress: 'Tổ 13, Ấp Bến 2, X. Trừ Văn Thố, TP. HCM', monogram: 'V & S', dateISO: '2026-07-05T10:30:00+07:00', dateText: 'Chủ Nhật · 05 . 07 . 2026', dateLong: 'Chủ Nhật, ngày 05 tháng 07 năm 2026 (nhằm 21/05 Bính Ngọ)', weekday: 'Chủ Nhật', day: '05', month: '07', year: '2026', ceremony: { label: 'Lễ Tân Hôn', time: '10:30', note: 'Tại tư gia nhà trai' }, party: { label: 'Tiệc Mừng', time: '11:30', note: 'Đón khách 11:30 · Nhập tiệc 12:00' }, venue: 'Hoa Viên Vườn Mai', venueFull: 'Hoa Viên Vườn Mai', hall: '16A Đường 189, Ấp 3, X. Bình Mỹ', address: 'Huyện Củ Chi, TP. Hồ Chí Minh', mapsLink: 'https://www.google.com/maps/search/?api=1&query=Hoa+Vien+Vuon+Mai+16A+Duong+189+Ap+3+Binh+My+Cu+Chi', mapsEmbed: 'https://www.google.com/maps?q=Hoa%20Vien%20Vuon%20Mai%2016A%20Duong%20189%20Ap%203%20Binh%20My%20Cu%20Chi&output=embed', invite: 'Trân trọng kính mời quý vị cùng gia đình đến chung vui trong ngày trọng đại của chúng tôi. Sự hiện diện của quý vị là niềm vinh hạnh cho gia đình chúng tôi.', quote: 'Yêu nhau không phải là nhìn nhau, mà là cùng nhìn về một hướng.', quoteAuthor: 'Antoine de Saint-Exupéry', closing: 'Hẹn gặp quý vị trong ngày hạnh phúc của chúng tôi!', // Nhạc nền: dán URL trực tiếp (mp3/m4a/ogg). Để rỗng nếu không muốn. musicUrl: 'music/background.mp3', }; // ============================================================ // Reveal-on-scroll — ENGINE TWEEN BẰNG JAVASCRIPT // Ghi thẳng opacity + translateY từng khung hình (rAF) thay vì dựa // vào CSS animation (vốn hay bị "đóng băng" trong iframe xem trước). // Vừa mờ dần vừa trượt lên thật mượt trên mọi trình duyệt, kèm cơ // chế an toàn: chắc chắn hiện rõ sau thời gian tối đa dù có lỗi gì. // ============================================================ const RevealCtx = React.createContext(null); const _reduceMotion = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; const _RISE = 26; // px trượt lên ban đầu const _DUR = 820; // thời lượng tween (ms) const _SAFETY = 2200; // dự phòng: hiện rõ sau ngần này dù chưa cuộn tới // easeOutCubic — chậm dần ở cuối, cảm giác mềm "đặt xuống" const _easeOut = (t) => 1 - Math.pow(1 - t, 3); // Một engine dùng chung: gom các phần tử chờ hiện, mỗi lần cuộn/khung // hình kiểm tra phần tử nào lọt ngưỡng thì khởi động tween riêng cho nó. const _revealRoots = new Map(); // rootEl -> { pending:Set } function _ensureRoot(root) { if (_revealRoots.has(root)) return _revealRoots.get(root); const entry = { pending: new Set() }; // Khởi động tween mờ-dần + trượt-lên cho 1 phần tử const animate = (rec) => { if (rec.started) return; rec.started = true; entry.pending.delete(rec.el); const el = rec.el; if (_reduceMotion) { el.style.opacity = '1'; el.style.transform = 'none'; return; } const begin = (ts) => { const start = ts + rec.delay; const frame = (now) => { const p = Math.min(1, Math.max(0, (now - start) / _DUR)); const e = _easeOut(p); el.style.opacity = String(e); el.style.transform = 'translate3d(0,' + (_RISE * (1 - e)).toFixed(2) + 'px,0)'; if (p < 1) requestAnimationFrame(frame); else { el.style.opacity = '1'; el.style.transform = 'none'; el.style.willChange = 'auto'; } }; requestAnimationFrame(frame); }; requestAnimationFrame(begin); }; entry.check = () => { if (!entry.pending.size) return; const rb = root ? root.getBoundingClientRect() : { top: 0, bottom: window.innerHeight }; const limit = rb.top + (rb.bottom - rb.top) * 0.92; entry.pending.forEach((el) => { if (el.getBoundingClientRect().top < limit) animate(el.__reveal); }); }; entry.animate = animate; (root || window).addEventListener('scroll', entry.check, { passive: true }); window.addEventListener('resize', entry.check); _revealRoots.set(root, entry); return entry; } function useReveal(delay = 0) { const ref = React.useRef(null); const root = React.useContext(RevealCtx); React.useEffect(() => { const el = ref.current; if (!el) return; const entry = _ensureRoot(root); const rec = { el, delay, started: false }; el.__reveal = rec; // trạng thái ban đầu: ẩn + lùi xuống (đặt qua JS để chắc chắn áp dụng) if (!_reduceMotion) { el.style.opacity = '0'; el.style.transform = 'translate3d(0,' + _RISE + 'px,0)'; el.style.willChange = 'opacity, transform'; } entry.pending.add(el); // kiểm tra vài khung hình đầu để hiện ngay phần đã nằm trong màn hình let n = 0; const tick = () => { entry.check(); if (++n < 8) requestAnimationFrame(tick); }; requestAnimationFrame(tick); // an toàn: chắc chắn hiện rõ sau _SAFETY dù có chuyện gì const fb = setTimeout(() => entry.animate(rec), _SAFETY); return () => { entry.pending.delete(el); clearTimeout(fb); }; }, [root, delay]); return ref; } function Reveal({ children, delay = 0, style, className = '' }) { const ref = useReveal(delay); return (
{children}
); } // ---------- Photo placeholder (kéo ảnh thật vào thay sau) ---------- function Photo({ src, label = 'Ảnh cưới', style, className = '', round = 0 }) { return (
{src ? {label} : (
{label}
)}
); } // ---------- Botanical line sprig (trang trí tối giản, khẽ đung đưa) ---------- function Sprig({ size = 40, color = 'var(--sage)', flip = false, sway = true, className = '', style }) { return ( ); } // ---------- Countdown ---------- function useCountdown(targetISO) { const target = React.useMemo(() => new Date(targetISO).getTime(), [targetISO]); const calc = () => { const diff = Math.max(0, target - Date.now()); const d = Math.floor(diff / 86400000); const h = Math.floor((diff % 86400000) / 3600000); const m = Math.floor((diff % 3600000) / 60000); const s = Math.floor((diff % 60000) / 1000); return { d, h, m, s, done: diff === 0 }; }; const [t, setT] = React.useState(calc); React.useEffect(() => { const id = setInterval(() => setT(calc()), 1000); return () => clearInterval(id); }, [target]); return t; } function Countdown({ accent = 'var(--sage-deep)', sep = false }) { const { d, h, m, s } = useCountdown(WED.dateISO); const items = [[d, 'Ngày'], [h, 'Giờ'], [m, 'Phút'], [s, 'Giây']]; const pad = (n) => String(n).padStart(2, '0'); return (
{items.map(([v, lab], i) => (
{pad(v)} {lab}
{sep && i < items.length - 1 && ( · )}
))}
); } // ---------- Gallery (lưới ảnh + lightbox) ---------- function Gallery({ layout = 'mosaic', count = 6 }) { const [open, setOpen] = React.useState(null); const cells = Array.from({ length: count }, (_, i) => i); const tile = (i, extra) => (
setOpen(i)} style={{ cursor: 'pointer', ...extra }}>
); let grid; if (layout === 'mosaic') { grid = (
{tile(0, { gridColumn: '1 / 2', gridRow: '1 / 3' })} {tile(1, {})} {tile(2, {})} {tile(3, { gridColumn: '1 / 3', gridRow: '3 / 4', height: 132 })} {tile(4, {})} {tile(5, {})}
); } else if (layout === 'strip') { grid = (
{cells.map((i) => (
setOpen(i)} style={{ flex: '0 0 64%', scrollSnapAlign: 'center', cursor: 'pointer' }}>
))}
); } else { grid = (
{cells.map((i) => tile(i, { aspectRatio: '3 / 4' }))}
); } return ( <> {grid} {open !== null && (
setOpen(null)} style={{ position: 'absolute', inset: 0, zIndex: 40, background: 'rgba(40,42,34,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, backdropFilter: 'blur(4px)' }}>
)} ); } // ---------- Map ---------- function MapBlock({ height = 200, round = 4, frameColor = 'var(--line)' }) { return (
); } function MapButton({ block }) { return ( { e.currentTarget.style.background = 'var(--sage-deep)'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = 'var(--sage-deep)'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--sage-deep)'; e.currentTarget.style.borderColor = 'var(--line)'; }}> Xem chỉ đường ); } // ---------- Music player (nút nhạc nền nổi) ---------- function MusicPlayer({ tone = 'sage', fixed = false, signal = 0 }) { const [playing, setPlaying] = React.useState(false); const [armed, setArmed] = React.useState(false); const audioRef = React.useRef(null); const toggle = () => { const a = audioRef.current; setArmed(true); if (!a) { setPlaying((p) => !p); return; } if (a.paused) { a.play().then(() => setPlaying(true)).catch(() => setPlaying(true)); } else { a.pause(); setPlaying(false); } }; // Bắt đầu phát khi mở thiệp (chỉ khi đã gắn file nhạc) React.useEffect(() => { if (signal > 0) { const a = audioRef.current; if (a && a.src) { a.play().catch(() => {}); setPlaying(true); } } }, [signal]); const bg = tone === 'dark' ? 'rgba(40,42,34,.9)' : 'var(--paper)'; const fg = tone === 'dark' ? '#fff' : 'var(--sage-deep)'; return (
{/* URL nhạc lấy từ WED.musicUrl trong cùng file này */} {armed && playing && audioRef.current && !audioRef.current.src && (
Thêm file nhạc (.mp3) của bạn
)}
); } // ---------- Cánh hoa rơi (petals) — lớp phủ toàn màn hình, dịu nhẹ ---------- function Petals({ count = 16 }) { const petals = React.useMemo(() => { if (_reduceMotion) return []; const colors = ['#E7C9C2', '#D8C7A8', '#BCC4AE', '#EAD7CE', '#C9D2BC']; return Array.from({ length: count }, (_, i) => { return { left: (i / count) * 100 + (i % 3) * 4, size: 8 + (i % 5) * 2.4, delay: -(i * 1.7) % 18, dur: 13 + (i % 6) * 2.6, drift: (i % 2 ? 1 : -1) * (24 + (i % 4) * 14), rot: (i % 2 ? 1 : -1) * (160 + (i % 3) * 90), color: colors[i % colors.length], opacity: 0.5 + (i % 4) * 0.12, }; }); }, [count]); if (!petals.length) return null; return ( ); } // ---------- Section wrapper helpers ---------- function Section({ children, style, pad = '64px 32px' }) { return
{children}
; } Object.assign(window, { WED, RevealCtx, useReveal, Reveal, Photo, Sprig, useCountdown, Countdown, Gallery, MapBlock, MapButton, MusicPlayer, Section, Petals, __reduceMotion: _reduceMotion, });