/* Components — Tierra de Vino y Sal */ const { useState, useEffect, useRef, useMemo } = React; /* ---------- Helpers ---------- */ function useReveal(deps = []) { useEffect(() => { const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reveal = (el) => el.classList.add('in'); let io; const setup = () => { const els = document.querySelectorAll('.reveal:not(.in)'); if (reduce || !('IntersectionObserver' in window)) { els.forEach(reveal); return; } io = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) { reveal(e.target); io.unobserve(e.target); } }); }, { threshold: 0, rootMargin: '0px 0px -8% 0px' }); els.forEach(el => { // immediate reveal if already in viewport at mount const r = el.getBoundingClientRect(); if (r.top < window.innerHeight * 0.95 && r.bottom > 0) { reveal(el); } else { io.observe(el); } }); }; const raf = requestAnimationFrame(() => requestAnimationFrame(setup)); // Safety fallback: if anything is still hidden after 1.5s (e.g. observer // didn't fire because of layout quirks), force-reveal everything. const safety = setTimeout(() => { document.querySelectorAll('.reveal:not(.in)').forEach(reveal); }, 1500); return () => { cancelAnimationFrame(raf); clearTimeout(safety); if (io) io.disconnect(); }; }, deps); } function useCountdown(targetISO) { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t); }, []); const target = new Date(targetISO).getTime(); const diff = Math.max(0, target - now); const days = Math.floor(diff / 86400000); const hours = Math.floor((diff / 3600000) % 24); const minutes = Math.floor((diff / 60000) % 60); const seconds = Math.floor((diff / 1000) % 60); return { days, hours, minutes, seconds }; } function Editable({ tag = 'span', value, onChange, editable, ...rest }) { const Tag = tag; const ref = useRef(null); return ( onChange && onChange(e.currentTarget.innerText)} dangerouslySetInnerHTML={{ __html: value }} {...rest} /> ); } /* ---------- Nav ---------- */ function Nav({ t, lang, setLang }) { const [scrolled, setScrolled] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 30); window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); return () => window.removeEventListener('scroll', onScroll); }, []); const go = (id) => (e) => { e.preventDefault(); const el = document.getElementById(id); if (el) window.scrollTo({ top: el.offsetTop - 60, behavior: 'smooth' }); }; return ( ); } /* ---------- Hero ---------- */ function Hero({ t, heroVariant, editable, copy, setCopy }) { const cd = useCountdown('2026-05-18T09:00:00'); return (
{heroVariant === 'video' && (
)}
{t.hero.eyebrow} · {t.hero.release}

setCopy({title_1:v})} />
{t.hero.title_2} {t.hero.title_amp}
{t.hero.title_3}

{t.hero.author}

setCopy({pitch:v})} />

{[ ['days', cd.days], ['hours', cd.hours], ['minutes', cd.minutes], ['seconds', cd.seconds], ].map(([k, v]) => (
{String(v).padStart(2,'0')} {t.countdown[k]}
))}
{heroVariant !== 'video' && (
{heroVariant === 'portrait' ? Melissa Franco : Tierra de vino y sal — portada }
)}
); } /* ---------- Sinopsis ---------- */ function Synopsis({ t }) { return (

{t.synopsis.quote}

{t.synopsis.label}
{t.synopsis.place}

{t.synopsis.p1}

{t.synopsis.p2}

{t.synopsis.p3}

{t.synopsis.p4}

); } /* ---------- Personajes ---------- */ function Characters({ t }) { return (
{t.characters.eyebrow}

{t.characters.intro}

Emilia Espinosa
Emilia
{t.characters.emilia.role}

{t.characters.emilia.quote}

Álvaro Alcázar
Álvaro
{t.characters.alvaro.role}

{t.characters.alvaro.quote}

); } /* ---------- Mundo ---------- */ function World({ t }) { return (
{t.world.eyebrow}

{t.world.themes.map((th, i) => (
— {String(i+1).padStart(2,'0')}

{th.title}

{th.body}

))}
{t.world.keywords.map((k, i) => {k})}
); } /* ---------- Vídeo ---------- */ function Video({ t }) { const ref = useRef(null); const [playing, setPlaying] = useState(false); const onPlay = () => { if (!ref.current) return; ref.current.muted = false; ref.current.controls = true; ref.current.play(); setPlaying(true); }; return (
{t.video.eyebrow}

{t.video.title}

{t.video.intro}

); } /* ---------- Autora ---------- */ function Author({ t }) { return (
Melissa Franco
{t.author.eyebrow}

{t.author.name}

{t.author.place}

{t.author.bio_p1}

{t.author.bio_p2}

{t.author.bio_p3}

{t.author.prev_label}

    {t.author.prev.map((b,i) =>
  • {b.title} {b.meta}
  • )}
); } /* ---------- Compra ---------- */ function Buy({ t }) { return (
{t.buy.eyebrow}

{t.buy.intro}

Portada — Tierra de vino y sal
{t.buy.stores.map((s,i) => (
{s.name}
{s.meta}
))}
{t.buy.facts_label}
{t.buy.facts.map((f,i) => (
{f.label}
{f.value}
))}

); } /* ---------- Newsletter ---------- */ function Newsletter({ t }) { const [email, setEmail] = useState(''); const [status, setStatus] = useState(''); const submit = (e) => { e.preventDefault(); if (!/^[^@]+@[^@]+\.[^@]+$/.test(email)) { setStatus('invalid'); return; } setStatus('thanks'); setEmail(''); }; return (
{t.newsletter.eyebrow}

{t.newsletter.title}

{t.newsletter.intro}

setEmail(e.target.value)} placeholder={t.newsletter.placeholder} required />
{status === 'thanks' && t.newsletter.thanks} {status === 'invalid' && t.newsletter.invalid}
); } /* ---------- Footer ---------- */ function Footer({ t }) { const socials = [ { name: 'Instagram', href: 'https://instagram.com/melissafrancoescritora', handle: '@melissafrancoescritora', icon: }, { name: 'YouTube', href: 'https://youtube.com/@melissafrancoescritora', handle: '@melissafrancoescritora', icon: }, { name: 'Facebook', href: 'https://www.facebook.com/melissa.francotorrecilla', handle: 'Melissa Franco Torrecilla', icon: }, ]; return ( ); } Object.assign(window, { Nav, Hero, Synopsis, Characters, World, Video, Author, Buy, Newsletter, Footer, useReveal });