// ===== RIVAMAR Group — Componentes compartidos (v1.1) =====
const { useState, useEffect, useRef } = React;
// --- Marca / Logotipo (editable desde el CMS) ---
function Logo({ size = 'base', onClick }) {
const s = (typeof window !== 'undefined' && window.SITE) || {};
const nombre = s.brand_nombre || 'RIVAMAR';
const sub = s.brand_sub != null ? s.brand_sub : 'GROUP';
const inicial = s.brand_inicial || nombre.charAt(0) || 'R';
const dim = size === 'lg' ? 'text-2xl' : 'text-xl';
return (
{s.logo_img ? (
) : (
{inicial}
)}
{!s.logo_img && (
{nombre}
{sub ? {sub} : null}
)}
);
}
// --- Placeholder de imagen (rayado industrial) o imagen real si hay src ---
function ImgSlot({ label, className = '', tone = '#D4A52C', ratio, src }) {
const style = {};
if (ratio) style.aspectRatio = ratio;
if (src) {
return (
);
}
const placeholderStyle = {
...style,
backgroundColor: '#141c26',
backgroundImage: `repeating-linear-gradient(135deg, ${tone}16 0px, ${tone}16 2px, transparent 2px, transparent 11px)`,
};
return (
[ imagen ]
{label}
);
}
// --- Subida de imagen: archivo → dataURL reescalado (cabe en localStorage) ---
function fileToScaledDataURL(file, maxW = 1400, quality = 0.82) {
return new Promise((resolve, reject) => {
if (!file || !file.type.startsWith('image/')) { reject(new Error('No es una imagen')); return; }
const reader = new FileReader();
reader.onerror = reject;
reader.onload = (e) => {
const img = new Image();
img.onerror = reject;
img.onload = () => {
const scale = Math.min(1, maxW / img.width);
const w = Math.round(img.width * scale), h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
try { resolve(canvas.toDataURL('image/jpeg', quality)); }
catch (err) { reject(err); }
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
// --- Selector de imagen reutilizable: dropzone + URL ---
function ImagePicker({ value, onChange, ratio = '16/10', maxW = 1400 }) {
const [drag, setDrag] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const inputRef = useRef(null);
const handleFile = async (file) => {
setErr(''); setBusy(true);
try { onChange(await fileToScaledDataURL(file, maxW)); }
catch (e) { setErr('No se pudo procesar la imagen.'); }
setBusy(false);
};
const onDrop = (e) => {
e.preventDefault(); setDrag(false);
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) handleFile(f);
};
return (
{/* Dropzone */}
o
{/* URL */}
onChange(e.target.value)} />
{err &&
{err}
}
{value && (
onChange('')} className="text-xs font-semibold text-red-400 hover:text-red-300">Quitar imagen
)}
);
}
// --- Badge de estado de reserva ---
function EstadoBadge({ estado }) {
const map = {
pendiente: { bg: 'bg-orange-500/15', text: 'text-orange-400', dot: 'bg-orange-400', label: 'Pendiente' },
confirmada: { bg: 'bg-emerald-500/15', text: 'text-emerald-400', dot: 'bg-emerald-400', label: 'Confirmada' },
cancelada: { bg: 'bg-red-500/15', text: 'text-red-400', dot: 'bg-red-400', label: 'Cancelada' },
eliminada: { bg: 'bg-gray-500/15', text: 'text-gray-400', dot: 'bg-gray-500', label: 'Eliminada' },
};
const s = map[estado] || map.pendiente;
return (
{s.label}
);
}
// --- Píldora EXPONOR ---
function ExponorPill({ className = '' }) {
return (
★ EXPONOR −15%
);
}
// --- Botón ---
function Btn({ children, onClick, variant = 'primary', className = '', type = 'button', disabled }) {
const base = 'fx-shine inline-flex items-center justify-center gap-2 font-semibold rounded-lg transition-all min-h-[44px] px-5 text-sm tracking-wide disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-gold text-carbon hover:bg-copper hover:text-gray-50 shadow-lg shadow-gold/20 active:scale-[0.98]',
gold: 'bg-gold text-carbon hover:bg-goldBright shadow-lg shadow-gold/25 active:scale-[0.98]',
copper: 'bg-copper text-gray-50 hover:bg-copperDark shadow-lg shadow-copper/20 active:scale-[0.98]',
ghost: 'bg-white/5 text-gray-100 hover:bg-white/10 border border-white/10',
outline: 'bg-transparent text-gold border border-gold/50 hover:bg-gold/10',
};
return (
{children}
);
}
// --- Navbar corporativo ---
function Navbar({ view, go }) {
const [open, setOpen] = useState(false);
const [localesOpen, setLocalesOpen] = useState(false);
const links = [
{ id: 'home', label: 'Inicio' },
{ id: 'galeria', label: 'Galería' },
{ id: 'nosotros', label: 'Nosotros' },
{ id: 'convenios', label: 'Convenios B2B' },
];
const linkCls = (id) =>
`fx-underline relative px-3 py-2 rounded-md text-sm font-medium transition-colors min-h-[44px] flex items-center ${
view === id ? 'text-gold bg-white/5' : 'text-gray-300 hover:text-gray-50'
}`;
return (
go('home')} />
go('home')}>Inicio
go('exponor')}
className={`fx-shine relative inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-sm font-bold transition-all min-h-[44px] ${view === 'exponor' ? 'bg-exponor text-carbon' : 'bg-exponor/15 text-exponor border border-exponor/40 hover:bg-exponor hover:text-carbon'}`}>
★ EXPONOR 2026
{/* Locales dropdown */}
setLocalesOpen(true)} onMouseLeave={() => setLocalesOpen(false)}>
setLocalesOpen(!localesOpen)}>
Locales
{localesOpen && (
{LOCALES.map((l) => (
{ go('local', { slug: l.slug }); setLocalesOpen(false); }}
className="w-full text-left px-3 py-2.5 rounded-md hover:bg-white/5 transition-colors">
{l.nombre}
{l.tagline}
))}
)}
go('galeria')}>Galería
go('nosotros')}>Nosotros
go('convenios')}>Convenios B2B
go('admin')} className={`text-sm font-medium flex items-center gap-1.5 min-h-[44px] px-3 rounded-md transition-colors ${view === 'admin' ? 'text-gold' : 'text-gray-400 hover:text-gray-100'}`}>
Admin
go('reservas')} className="!px-4">Reservar Mesa
setOpen(!open)} aria-label="Menú">
{open && (
{ go('home'); setOpen(false); }}>Inicio
{ go('exponor'); setOpen(false); }}
className="flex items-center justify-between gap-2 px-3 py-3 rounded-lg bg-exponor/15 border border-exponor/40 text-exponor font-bold">
★ EXPONOR 2026 · 15% Dcto.
→
Nuestros locales
{LOCALES.map((l) => (
{ go('local', { slug: l.slug }); setOpen(false); }}>{l.nombre}
))}
{ go('galeria'); setOpen(false); }}>Galería
{ go('nosotros'); setOpen(false); }}>Nosotros
{ go('convenios'); setOpen(false); }}>Convenios B2B
{ go('admin'); setOpen(false); }}>Panel Admin
{ go('reservas'); setOpen(false); }} className="mt-2">Reservar Mesa (15% Dcto.)
)}
);
}
function ChevronDown() { return ; }
function LockIcon() { return ; }
// --- Barra EXPONOR sticky (solo móvil) — maximiza conversión ---
function ExponorMobileBar({ go }) {
const [closed, setClosed] = useState(false);
if (closed) return null;
return (
15%
Descuento EXPONOR 2026
Reserva con tu credencial
go('reservas', { exponor: true })} className="shrink-0 bg-gold text-carbon text-xs font-bold px-3 py-2 rounded-lg min-h-[40px]">Reservar
setClosed(true)} aria-label="Cerrar" className="shrink-0 text-gray-500 hover:text-gray-200 w-7 h-7 grid place-items-center">✕
);
}
// --- Utilidad: escribir en una ruta tipo 'site.hero_titulo' o 'locales.0.nombre' ---
function setPath(obj, path, value) {
const keys = path.split('.');
const root = Array.isArray(obj) ? obj.slice() : { ...obj };
let cur = root;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
const next = cur[k];
cur[k] = Array.isArray(next) ? next.slice() : { ...next };
cur = cur[k];
}
cur[keys[keys.length - 1]] = value;
return root;
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>');
}
// --- Texto editable inline (solo en modo edición del admin) ---
function Editable({ as = 'span', value, path, edit, onEdit, className = '' }) {
if (!edit) return React.createElement(as, { className }, value);
return React.createElement(as, {
className,
'data-cms': path,
title: 'Clic para editar',
contentEditable: true,
suppressContentEditableWarning: true,
spellCheck: false,
onBlur: (e) => { const t = e.currentTarget.innerText.trim(); if (t !== value) onEdit(path, t); },
dangerouslySetInnerHTML: { __html: escapeHtml(value) },
});
}
// --- Hook de scroll reveal (no-op: la lógica vive en un script global del HTML,
// inmune a los re-renders de React) ---
function useReveal(dep) {
useEffect(() => {
if (window.__revealKick) window.__revealKick();
}, [dep]);
}
// --- Barra flotante de Modo Edición (visible solo tras login admin) ---
function EditModeBar({ edit, setEdit, go }) {
return (
{edit ? 'Modo edición activo — clic sobre los textos' : 'Vista previa'}
setEdit(!edit)} className={`text-xs font-bold px-3 py-1.5 rounded-full transition-colors ${edit ? 'bg-gold text-carbon' : 'bg-white/10 text-gray-200 hover:bg-white/20'}`}>
{edit ? 'Editando' : 'Activar'}
go('admin')} className="text-xs font-medium text-gray-400 hover:text-gold px-2">Panel ↗
);
}
// --- Banner Revista Oficial MCH (reutilizable, editable desde CMS) ---
function RevistaMCH({ className = '' }) {
const s = (typeof window !== 'undefined' && window.SITE) || {};
const url = s.mch_url || 'https://www.mch.cl/';
const eyebrow = s.mch_eyebrow || 'Prensa minera oficial';
const titulo = s.mch_titulo || 'Aparecemos en la Revista Oficial de la Minería Chilena';
const texto = s.mch_texto || 'Conoce la cobertura de EXPONOR 2026 y descubre por qué las principales empresas mineras del país eligen a RIVAMAR Group.';
const boton = s.mch_boton || 'Ver revista oficial';
return (
{eyebrow}
{titulo}
{texto}
{boton}
);
}
// --- Botón "ir arriba" (aparece al bajar) ---
function ScrollTop() {
const [show, setShow] = useState(false);
useEffect(() => {
const onScroll = () => setShow(window.scrollY > 500);
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => window.removeEventListener('scroll', onScroll);
}, []);
if (!show) return null;
return (
window.scrollTo({ top: 0, behavior: 'smooth' })} aria-label="Volver arriba"
className="fixed bottom-24 left-4 lg:bottom-6 lg:left-6 z-50 grid place-items-center w-12 h-12 rounded-full bg-steel/90 backdrop-blur border border-gold/40 text-gold shadow-xl hover:bg-gold hover:text-carbon transition-all hover:-translate-y-0.5 animate-fade-up">
);
}
// --- Botón flotante de WhatsApp ---
function WhatsAppFAB({ whatsapp = '56900000000' }) {
const [hover, setHover] = useState(false);
const msg = encodeURIComponent('¡Hola! Me gustaría hacer una consulta sobre reservas en RIVAMAR Group.');
return (
setHover(true)} onMouseLeave={() => setHover(false)}
className="fixed bottom-20 lg:bottom-6 right-6 z-50 flex items-center gap-3 group">
{hover && (
Habla con un ejecutivo
)}
);
}
// --- Footer ---
function SocialIcon({ kind }) {
const p = {
ig: ,
fb: ,
in: ,
};
return {p[kind]} ;
}
function Footer({ go, cms }) {
const s = cms || (typeof window !== 'undefined' && window.SITE) || {};
const socials = [['ig', s.social_ig], ['fb', s.social_fb], ['in', s.social_in]];
return (
);
}
// --- Sección con título ---
function SectionHead({ eyebrow, title, desc, center }) {
return (
{eyebrow &&
{eyebrow}
}
{title}
{desc &&
{desc}
}
);
}
// --- Conteo animado (estadísticas) ---
function CountUp({ end, suffix = '', dur = 1400 }) {
const [val, setVal] = useState(0);
const ref = useRef(null);
useEffect(() => {
let started = false;
const obs = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !started) {
started = true;
const t0 = performance.now();
const tick = (t) => {
const p = Math.min((t - t0) / dur, 1);
const eased = 1 - Math.pow(1 - p, 3);
setVal(Math.round(end * eased));
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
}, { threshold: 0.4 });
if (ref.current) obs.observe(ref.current);
return () => obs.disconnect();
}, [end, dur]);
return {val}{suffix} ;
}
Object.assign(window, {
Logo, ImgSlot, EstadoBadge, ExponorPill, Btn, Navbar, WhatsAppFAB, Footer, SectionHead,
LockIcon, CountUp, SocialIcon, ExponorMobileBar, ImagePicker, fileToScaledDataURL,
setPath, escapeHtml, Editable, useReveal, EditModeBar, ScrollTop, RevistaMCH,
});