// Variant B — Shared components
// ---- Scroll reveal hook (uses IntersectionObserver) ------------------------
const useReveal = (options = {}) => {
const ref = React.useRef(null);
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
// Find the nearest scrollable ancestor so the observer works inside
// the artboard's overflow container.
let root = el.parentElement;
while (root) {
const s = getComputedStyle(root);
if (/(auto|scroll)/.test(s.overflowY) || /(auto|scroll)/.test(s.overflow)) break;
root = root.parentElement;
}
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
setVisible(true);
io.unobserve(e.target);
}
});
},
{ root: root || null, rootMargin: options.rootMargin || "0px 0px -10% 0px", threshold: options.threshold || 0.1 }
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, visible];
};
const Reveal = ({ children, delay = 0, y = 24, as: Tag = "div", style, ...rest }) => {
const [ref, visible] = useReveal();
return (
{children}
);
};
// Marquee ticker — infinite horizontal scroll
const Marquee = ({ items, speed = 30, color = B.ink, bg = B.cream, border = true }) => (
{[...items, ...items, ...items].map((t, i) => (
{t}
✦
))}
);
// ---- Floating WhatsApp FAB --------------------------------------------------
const BWhatsAppFab = () => {
const [open, setOpen] = React.useState(false);
const [hover, setHover] = React.useState(false);
const services = [
{ key: "medicina", ...CLINIC.whatsapp.medicina, icon: "stethoscope" },
{ key: "odonto", ...CLINIC.whatsapp.odonto, icon: "tooth" },
{ key: "lab", ...CLINIC.whatsapp.lab, icon: "flask" },
];
const openWA = (raw, label) => {
const msg = encodeURIComponent(`Hola, me gustaría agendar una cita de ${label}.`);
window.open(`https://wa.me/${raw}?text=${msg}`, "_blank");
setOpen(false);
};
return (
{open && (
Elija servicio
{services.map((s) => (
))}
)}
{!open && (
)}
);
};
const BPhotoPh = ({ label, height = 200, bg = B.creamDark, src }) => (
{src ? (

) : (
[ {label} ]
)}
);
const BEyebrow = ({ children, color = B.red }) => (
— {children}
);
// ---- Featured service card (promos) ----------------------------------------
const BFeaturedCard = ({ item, compact = false }) => {
const [hover, setHover] = React.useState(false);
const isNavy = item.accent === "navy";
const bg = isNavy ? B.navy : B.red;
const onDark = true;
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: "block", background: bg, color: B.white,
padding: compact ? 20 : 32, textDecoration: "none",
position: "relative", overflow: "hidden",
transform: hover ? "translateY(-4px)" : "translateY(0)",
boxShadow: hover ? "0 24px 60px rgba(0,0,0,0.25)" : "0 0 0 rgba(0,0,0,0)",
transition: "transform 0.35s cubic-bezier(0.2,0.8,0.2,1), box-shadow 0.35s ease",
}}>
— {item.kicker}
{item.price && (
{item.price}
)}
{item.title}.
{item.copy}
Contáctanos al {item.cta}
);
};
// ---- Gallery tile -----------------------------------------------------------
const BGalleryTile = ({ item, height = 200 }) => {
const [hover, setHover] = React.useState(false);
const isRed = item.tone === "red";
const accent = isRed ? B.red : B.navy;
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: "relative", overflow: "hidden", cursor: "pointer",
height, background: B.creamDark,
border: `1px solid ${B.border}`,
}}>
{item.label}
→
);
};
// ---- Services accordion ----------------------------------------------------
const BServicesAccordion = ({ compact = false }) => {
const [open, setOpen] = React.useState("medicina");
const services = [
{ key: "medicina", label: "Medicina General", icon: "stethoscope", num: "01", phone: CLINIC.whatsapp.medicina, sub: CLINIC.services.medicina.sub },
{ key: "lab", label: "Laboratorio Clínico", icon: "flask", num: "02", phone: CLINIC.whatsapp.lab, sub: CLINIC.services.lab.sub },
{ key: "odonto", label: "Odontología", icon: "tooth", num: "03", phone: CLINIC.whatsapp.odonto, sub: CLINIC.services.odonto.sub },
];
return (
{services.map((s) => {
const isOpen = open === s.key;
const items = CLINIC.services[s.key].items;
return (
{items.map((it, idx) => (
{it}
))}
);
})}
);
};
// ---- FAQ --------------------------------------------------------------------
const BFaq = ({ compact = false }) => {
const [open, setOpen] = React.useState(0);
return (
{FAQ.map((f, i) => {
const isOpen = open === i;
return (
);
})}
);
};
// ---- Contact form -----------------------------------------------------------
const BContactForm = () => {
const [form, setForm] = React.useState({ name: "", phone: "", service: "medicina", message: "" });
const [errors, setErrors] = React.useState({});
const [sent, setSent] = React.useState(false);
const [focus, setFocus] = React.useState(null);
const validate = () => {
const e = {};
if (!form.name.trim() || form.name.trim().length < 2) e.name = "Ingrese su nombre";
if (!/^[0-9\-\s]{7,}$/.test(form.phone)) e.phone = "Teléfono inválido";
if (!form.message.trim() || form.message.trim().length < 5) e.message = "Mensaje muy corto";
setErrors(e);
return Object.keys(e).length === 0;
};
const onSubmit = (e) => {
e.preventDefault();
if (!validate()) return;
const svc = form.service === "medicina" ? CLINIC.whatsapp.medicina : form.service === "lab" ? CLINIC.whatsapp.lab : CLINIC.whatsapp.odonto;
const msg = encodeURIComponent(`Hola, soy ${form.name} (tel. ${form.phone}). Servicio: ${svc.label}.\n\n${form.message}`);
window.open(`https://wa.me/${svc.raw}?text=${msg}`, "_blank");
setSent(true);
};
const input = (key, ok) => ({
width: "100%", padding: "14px 0 10px", background: "transparent",
border: "none", borderBottom: `1px solid ${ok === false ? B.red : (focus === key ? B.red : B.ink)}`,
fontSize: 16, fontFamily: "'Inter', sans-serif",
outline: "none", color: B.ink,
boxSizing: "border-box",
transition: "border-color 0.3s ease",
});
const label = { fontSize: 11, fontWeight: 700, color: B.ink3, textTransform: "uppercase", letterSpacing: 1.8, display: "block" };
if (sent) {
return (
Mensaje enviado.
Se abrió WhatsApp en una nueva ventana. Le responderemos a la brevedad.
);
}
return (
);
};
Object.assign(window, {
BWhatsAppFab, BPhotoPh, BEyebrow, BServicesAccordion, BContactForm,
BFeaturedCard, BGalleryTile, BFaq, Reveal, useReveal, Marquee,
});