/* global React, CATALOGO, CAT_LABELS, detectarPatron, Placeholder, Arr, HeartIcon, useWishlist, waLink */ const { useState, useMemo, useEffect, useCallback, useRef } = React; /* ════════════════════════════════════════════════════════════════════════════ FICHA DE PRODUCTO · revisión 6 — FEED tipo red social Navegación NATIVA (scroll-snap), no detección de gestos: · Scroll VERTICAL (arriba/abajo) → cambia de producto, hace snap al siguiente/anterior de la categoría. Como TikTok/Reels. · Scroll HORIZONTAL (lados) dentro de cada producto → sus fotos. Iconos en COLUMNA vertical, abajo a la derecha (a la altura del pulgar). Copy abajo a la izquierda. CTA "Lo quiero" en el copy. La info (medidas, patrón) vive en los paneles Dudas / Arma la tuya. Vuelta al catálogo sin menú: pastilla de vidrio arriba. ════════════════════════════════════════════════════════════════════════════ */ const WA_DUDAS = null; /* Tres acciones. Armar la pieza NO es un botón: vive dentro de Dudas, porque armar es parte de resolver la duda antes de vender — y no todo es armable. */ const RAIL = { gusta: "Me gusta", dudas: "Dudas", enviar: "Muéstrasela" }; const LABEL_EXTRA = { "comedores-perros": "Comedores para perros" }; const labelCat = (c) => (window.CAT_LABELS && window.CAT_LABELS[c]) || LABEL_EXTRA[c] || c || ""; function pushEvento(evento, p, extra) { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ ecommerce: null }); window.dataLayer.push(Object.assign({ event: evento, ecommerce: { currency: "COP", value: p.precio, items: [{ item_id: p.id, item_name: p.nombre, item_variant: p.variante || "", item_category: labelCat(p.categoria), price: p.precio }] }, }, extra || {})); } /* Detección de fotos extra (_2…_7) + video. Perezosa: solo cuando el slide se activa, para no lanzar cientos de peticiones al abrir el feed. */ function useMediaLazy(p, activo) { const [items, setItems] = useState(p?.foto ? [{ type: "img", url: p.foto }] : []); const hecho = useRef(false); useEffect(() => { if (!p || !activo || hecho.current) return; hecho.current = true; const base = p.foto ? p.foto.replace(/\.[^.]+$/, "").replace(/_\d+$/, "") : null; const ext = p.foto ? (p.foto.match(/\.([^.?]+)(\?|$)/)?.[1] || "jpg") : "jpg"; const checkImg = (url) => new Promise((res) => { const im = new window.Image(); im.onload = () => res({ type: "img", url }); im.onerror = () => res(null); im.src = url + "?t=" + Date.now(); }); const checkVideo = (url) => fetch(url, { method: "HEAD" }) .then(r => (r.ok && r.headers.get("content-type")?.includes("video")) ? { type: "video", url } : null) .catch(() => null); const fotoChecks = base ? Array.from({ length: 6 }, (_, i) => checkImg(`${base}_${i + 2}.${ext}`)) : []; const videoCheck = ["mp4", "mov"].reduce((acc, e) => acc.then(f => f || checkVideo(`/assets/productos/${p.id}_video.${e}`)), Promise.resolve(null)); Promise.all([Promise.all(fotoChecks), videoCheck]).then(([extra, video]) => { const fotos = [{ type: "img", url: p.foto }, ...extra.filter(Boolean)]; setItems(video ? [...fotos, video] : fotos); }); }, [p?.id, activo]); return items; } /* Muestrea el color de fondo de una foto (promedio de las 4 esquinas). Con eso el slide adopta ese color: la foto de estudio en blanco se funde con un fondo blanco (piece flotando), el degradado se funde con la foto, y el texto se oscurece o aclara según la luminancia. Cae a oscuro si algo falla. */ function useBgColor(url) { const [bg, setBg] = useState(null); // { rgb:[r,g,b], light:bool } useEffect(() => { if (!url) { setBg(null); return; } let vivo = true; const im = new window.Image(); im.crossOrigin = "anonymous"; im.onload = () => { try { const c = document.createElement("canvas"); c.width = 24; c.height = 24; const ctx = c.getContext("2d"); ctx.drawImage(im, 0, 0, 24, 24); let r = 0, g = 0, b = 0; [[1, 1], [22, 1], [1, 22], [22, 22]].forEach(([x, y]) => { const d = ctx.getImageData(x, y, 1, 1).data; r += d[0]; g += d[1]; b += d[2]; }); r = Math.round(r / 4); g = Math.round(g / 4); b = Math.round(b / 4); const lum = 0.299 * r + 0.587 * g + 0.114 * b; if (vivo) setBg({ rgb: [r, g, b], light: lum > 150 }); } catch (e) { if (vivo) setBg(null); } }; im.onerror = () => { if (vivo) setBg(null); }; im.src = url; return () => { vivo = false; }; }, [url]); return bg; } /* ── Colores de la pieza, extraídos de la foto ────────────────────────────── El catálogo todavía no tiene el campo `color` (lo cargará la Fase 3), pero la información SÍ existe: está en la imagen. Se cuantizan los píxeles del tejido (descartando fondo blanco y sombras) y se proponen los dominantes. REGLA: el sistema PROPONE, Danny APRUEBA. Por eso se muestran marcados como propuesta, nunca como dato confirmado. */ function useColores(url, activo) { const [cols, setCols] = useState([]); useEffect(() => { if (!url || !activo) return; let vivo = true; const im = new window.Image(); im.crossOrigin = "anonymous"; im.onload = () => { try { const N = 64; const c = document.createElement("canvas"); c.width = N; c.height = N; const ctx = c.getContext("2d"); ctx.drawImage(im, 0, 0, N, N); const d = ctx.getImageData(0, 0, N, N).data; const cubos = new Map(); for (let i = 0; i < d.length; i += 4) { const r = d[i], g = d[i + 1], b = d[i + 2], a = d[i + 3]; if (a < 200) continue; const lum = 0.299 * r + 0.587 * g + 0.114 * b; if (lum > 232 || lum < 26) continue; // fondo blanco / sombra dura const k = `${r >> 5}-${g >> 5}-${b >> 5}`; // cuantiza a bloques de 32 const prev = cubos.get(k) || { n: 0, r: 0, g: 0, b: 0 }; cubos.set(k, { n: prev.n + 1, r: prev.r + r, g: prev.g + g, b: prev.b + b }); } const orden = [...cubos.values()].sort((a, b) => b.n - a.n) .map(o => [Math.round(o.r / o.n), Math.round(o.g / o.n), Math.round(o.b / o.n)]); // Descarta tonos demasiado parecidos entre sí const elegidos = []; for (const rgb of orden) { if (elegidos.length >= 5) break; const lejos = elegidos.every(e => Math.abs(e[0] - rgb[0]) + Math.abs(e[1] - rgb[1]) + Math.abs(e[2] - rgb[2]) > 70); if (lejos) elegidos.push(rgb); } if (vivo) setCols(elegidos); } catch (e) { if (vivo) setCols([]); } }; im.onerror = () => { if (vivo) setCols([]); }; im.src = url; return () => { vivo = false; }; }, [url, activo]); return cols; } function useInteres(id, activo) { const [count, setCount] = useState(null); const [mine, setMine] = useState(false); const hecho = useRef(false); useEffect(() => { if (!id || !activo || hecho.current) return; hecho.current = true; let vivo = true; fetch(`/likes.php?id=${encodeURIComponent(id)}`) .then(r => r.ok ? r.json() : null) .then(d => { if (vivo && d && typeof d.count === "number") { setCount(d.count); setMine(!!d.mine); } }) .catch(() => {}); return () => { vivo = false; }; }, [id, activo]); const toggle = useCallback((quiero) => { setMine(quiero); setCount(c => (c === null ? c : Math.max(0, c + (quiero ? 1 : -1)))); fetch("/likes.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, on: quiero }) }) .then(r => r.ok ? r.json() : null) .then(d => { if (d && typeof d.count === "number") { setCount(d.count); setMine(!!d.mine); } }) .catch(() => {}); }, [id]); return { count, mine, toggle }; } const PATRON_TEXTS = { "Precolombino especial": "El más elaborado del taller — doble tensión y el doble de tiempo que un tejido estándar. Cada rombo es un conteo exacto, hilo a hilo.", "Precolombino": "Nuestra firma más reconocida. La geometría en rombos se teje contando hilo a hilo, sin plantilla — solo la memoria de las manos.", "Lineal con refuerzo": "Intercala bandas de doble hilo: más resistencia y un acabado visual limpio que no pasa desapercibido.", "Lineal": "El más puro del catálogo — líneas paralelas tensadas con precisión milimétrica que crean un efecto óptico de movimiento.", "Diamante": "Geometría en estado puro. Cada figura exige cambiar la dirección del hilo en ángulos exactos — solo sale bien a mano.", "Pirámide": "Crea volumen con una progresión de líneas que se estrechan hacia el centro. Cambia según el ángulo desde el que se mire.", "Espiga": "Toma su nombre del grano de trigo — diagonales que se encuentran en un eje central y generan movimiento continuo.", "Rombo": "Enmarca el asiento con figuras de cuatro puntas. Cada vértice tiene que cerrarse perfectamente.", "Cruz": "Alterna bloques horizontales y verticales que se cruzan en puntos exactos. La dificultad está en la tensión pareja en los dos ejes.", "Square": "Una cuadrícula perfecta con cruces de hilo que definen cada celda. Limpio, moderno, preciso.", "Tapizado": "La técnica más densa del taller — el hilo cubre toda la superficie sin dejar espacio, para un asiento firme y cálido.", }; const MENSAJES = [ "Si me vas a regalar algo este año, que sea esto. 👇", "Dejo esto por aquí. Sin presión ninguna. (Con toda la presión.)", "Ya sé qué quiero. Llevo días mirándolo.", "Necesito tu ojo un segundo. ¿Sí o no?", "¿Esto para la casa? Dime la verdad, no lo que quiero oír.", "Llevo tres días pensando en esto. ¿Estoy loco?", "Mi cumpleaños es pronto. Esto es todo lo que voy a decir.", "Encontré la pieza. La única. Mírala.", "¿Nos damos este gusto o seguimos siendo responsables?", "Esto se teje a mano y se demora 15 días. Hay que decidir ya.", ]; const Trazo = ({ d, size = 22 }) => ( ); const IconDudas = ({ size }) => } />; const IconEnviar = ({ size }) => } />; const IconGrid = ({ size = 15 }) => } />; /* Botón del rail vertical. Se adapta al fondo: sobre fondo claro usa círculo blanco con icono oscuro; sobre oscuro, círculo oscuro con icono blanco. */ function AccBtn({ icon, label, sub, onClick, activo, light }) { const iconColor = activo ? "#fff" : (light ? "var(--ink,#1A1714)" : "#fff"); const labelColor = light ? "rgba(26,23,20,.72)" : "#fff"; return ( ); } /* ── Recomendación precisa ────────────────────────────────────────────────── No manda a navegar todo el catálogo: puntúa qué tan parecida es cada pieza y devuelve solo las más cercanas. Mismo modelo pesa más que misma categoría. */ function similares(p, n = 6) { if (!p) return []; const pat = detectarPatron(p.variante); const puntaje = (x) => { let s = 0; if (x.nombre === p.nombre) s += 50; // mismo modelo, otro tejido const xp = detectarPatron(x.variante); if (pat && xp === pat) s += 30; // mismo patrón if (x.categoria === p.categoria) s += 20; // misma familia if (p.precio && x.precio) { // rango de precio cercano const d = Math.abs(x.precio - p.precio) / p.precio; if (d <= 0.25) s += 10; else if (d <= 0.5) s += 4; } if (x.material && x.material === p.material) s += 5; return s; }; return CATALOGO .filter((x) => x.id !== p.id && x.foto) .map((x) => ({ x, s: puntaje(x) })) .filter((o) => o.s > 0) .sort((a, b) => b.s - a.s) .slice(0, n) .map((o) => o.x); } /* Un producto = una pantalla del feed. Fotos en scroll horizontal nativo. El fondo del slide adopta el color de la foto (blanco de estudio o ambiente), la foto va CONTENIDA (alejada, pieza completa) y el degradado se funde con ese color. Texto y rail se aclaran/oscurecen según la luminancia. */ function Slide({ prod, activo, overlay, wl, onPanel, onLoQuiero, onGuardarEv, setLightbox, onElegirColores }) { const media = useMediaLazy(prod, activo || overlay); const interes = useInteres(prod.id, activo); const [foto, setFoto] = useState(0); const scroller = useRef(null); const touch = useRef(null); const goFoto = (index) => { const el=scroller.current; if (!el) return; const next=Math.max(0,Math.min(media.length-1,index)); el.scrollLeft=next*el.clientWidth; setFoto(next); }; const bg = useBgColor(media[foto]?.url || prod.foto); const patron = detectarPatron(prod.variante); const isOn = wl.has(prod.id) || interes.mine; const precioFmt = prod.precio ? prod.precio.toLocaleString("es-CO") : "por confirmar"; const msgPedir = `Hola DAVA'S, quiero esta pieza: "${prod.nombre}${prod.variante ? " — " + prod.variante : ""}" (${prod.id}), ${prod.precio ? "$" + precioFmt : "precio por confirmar"}. ¿Cómo arrancamos?`; const rgb = bg ? bg.rgb : [18, 10, 1]; const light = bg ? bg.light : false; const solid = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; const rgba = (a) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`; const tCol = light ? "var(--ink,#1A1714)" : "#F7F2EA"; const tSoft = light ? "rgba(26,23,20,.62)" : "rgba(247,242,234,.82)"; const tFaint = light ? "rgba(26,23,20,.4)" : "rgba(247,242,234,.55)"; const shadow = light ? "none" : "0 1px 12px rgba(0,0,0,.8)"; const precioCol = light ? "var(--accent,#C13B1F)" : "#E2B25C"; const onScrollFotos = () => { const el = scroller.current; if (!el) return; setFoto(Math.round(el.scrollLeft / el.clientWidth)); }; const onGuardar = () => { const quiero = !isOn; wl.toggle({ id: prod.id, name: prod.nombre, family: labelCat(prod.categoria), pattern: patron, price: precioFmt }); interes.toggle(quiero); if (quiero) onGuardarEv(prod); }; return (
{/* Fotos — scroll horizontal nativo con snap. Contenidas (alejadas). */}
{ if(e.key === "ArrowRight") {e.preventDefault();goFoto(foto+1);} if(e.key === "ArrowLeft") {e.preventDefault();goFoto(foto-1);} }} onTouchStart={e => { const t=e.touches[0]; touch.current={x:t.clientX,y:t.clientY,index:foto}; }} onTouchEnd={e => { const start=touch.current,t=e.changedTouches[0]; touch.current=null; if(start&&t){const dx=t.clientX-start.x,dy=t.clientY-start.y;if(Math.abs(dx)>45&&Math.abs(dx)>Math.abs(dy)*1.3)goFoto(start.index+(dx<0?1:-1));} }} onTouchCancel={() => { touch.current=null; }} style={{ position: "absolute", inset: 0, display: "flex", overflowX: "auto", overflowY: "hidden", scrollSnapType: "x mandatory", touchAction:"pan-x pan-y", WebkitOverflowScrolling: "touch" }}> {media.map((m, i) => (
{m.type === "video" ?
))}
{/* Degradado que se FUNDE con el color de la foto (no una banda oscura) */} {media.length > 1 &&
{foto+1} / {media.length}
}
{overlay && ( <> {/* Rail VERTICAL, abajo a la derecha (altura del pulgar) */}
} label={RAIL.gusta} sub={interes.count > 0 ? String(interes.count) : null} activo={isOn} onClick={onGuardar} light={light} /> } label={RAIL.dudas} onClick={() => onPanel("dudas", prod)} light={light} /> } label={RAIL.enviar} onClick={() => onPanel("enviar", prod)} light={light} />
{/* Copy abajo a la izquierda */}
{media.length > 1 && (
{media.map((_, i) => (
)}
{patron ? `${patron} · se teje para ti` : "Se teje para ti · 15 días"}

{prod.nombre}

{prod.variante ? (
{prod.variante}
) : null}
{prod.precio ? `$ ${precioFmt}` : "Consultar precio"} COP
)}
); } function Ficha({ productId, setPage, openFicha, openModelo, goCatalog }) { const wl = useWishlist(); const pid = (productId || "").toString().toUpperCase(); const p0 = CATALOGO.find((x) => (x.id || "").toUpperCase() === pid); const cont = useRef(null); const [activo, setActivo] = useState(0); const [panel, setPanel] = useState(null); // { tipo, prod } | null const [msg, setMsg] = useState(0); const [lightbox, setLightbox] = useState(null); // { prod, media, i } | null /* Tres frames diseñados, no una lista infinita de productos: 1 · la pieza (hero con acciones) 2 · sus parámetros: descripción, patrón, colores, estructura 3 · similares en carrusel + cómo se hace + salida al catálogo */ const recomendadas = useMemo(() => similares(p0, 10), [p0?.id]); const slides = useMemo(() => { if (!p0) return []; /* El frame de armar solo existe si esa estructura tiene imagen croma. Sin imagen no hay nada honesto que simular, así que no se muestra. */ const s = [{ tipo: "prod", prod: p0 }]; s.push({ tipo: "cierre" }); return s; }, [p0?.id]); /* Entra siempre en la pieza pedida (índice 0). */ useEffect(() => { const el = cont.current; if (!el) return; el.scrollTop = 0; setActivo(0); }, [p0?.id]); /* Al hacer scroll, detecta el slide activo; si es producto, actualiza URL y mide. */ useEffect(() => { const el = cont.current; if (!el) return; /* Cálculo directo, sin requestAnimationFrame: es una división y una comparación. Con rAF, un navegador que lo estrangule (pestaña en segundo plano) dejaba el feed sin actualizar. */ const onScroll = () => { if (!el.clientHeight) return; const i = Math.round(el.scrollTop / el.clientHeight); if (i !== activo && slides[i]) { setActivo(i); const s = slides[i]; if (s.tipo === "prod") { const prod = s.prod; const path = prod.catSlug && prod.slug ? `/${prod.catSlug}/${prod.slug}` : `/producto/${prod.id.toLowerCase()}`; window.history.replaceState({ page: "ficha", params: { productId: prod.id } }, "", path); pushEvento("view_item", prod); } } }; el.addEventListener("scroll", onScroll, { passive: true }); return () => el.removeEventListener("scroll", onScroll); }, [activo, slides]); /* Bloquea el scroll del feed cuando hay panel/lightbox. */ useEffect(() => { const el = cont.current; if (!el) return; el.style.overflowY = (panel || lightbox) ? "hidden" : "scroll"; }, [panel, lightbox]); useEffect(() => { setMsg(Math.floor(Math.random() * MENSAJES.length)); }, [panel?.prod?.id]); if (!p0) { return (
Producto no encontrado

); } const prodActivo = (slides[activo] && slides[activo].tipo === "prod") ? slides[activo].prod : p0; return ( <> {/* Vuelta al catálogo sin menú — pastilla de vidrio arriba, centrada */} {/* FEED vertical con snap */}
{slides.map((s, i) => (
{s.tipo === "prod" && ( setPanel({ tipo: "colores", prod: s.prod }) : null} onPanel={(tipo, pr) => { pushEvento("preguntar", pr, { etapa: "consideracion", destino: tipo }); setPanel({ tipo, prod: pr }); }} onLoQuiero={(pr) => pushEvento("pedir", pr, { etapa: "conversion", lead_source: "whatsapp_ficha" })} onGuardarEv={(pr) => pushEvento("guardar", pr, { etapa: "interes" })} setLightbox={setLightbox} /> )} {s.tipo === "armar" && ( pushEvento("pedir", pr, { etapa: "conversion", lead_source: "configurador" })} /> )} {s.tipo === "cierre" && ( )}
))}
{/* SEO oculto del producto activo */} {/* ═══ PANELES ═══ */} {panel?.tipo === "colores" && setPanel(null)} titulo="Hazla tuya." subtitulo={panel.prod.nombre}> pushEvento("pedir", pr, {lead_source:"colores"})} /> } {panel?.tipo === "dudas" && ( setPanel(null)} openFicha={(id) => { setPanel(null); openFicha(id); }} /> )} {panel?.tipo === "enviar" && setPanel(null)} />} {/* ═══ LIGHTBOX ═══ */} {lightbox && ( setLightbox(null)} /> )} ); } /* ── Paneles ──────────────────────────────────────────────────────────────── */ function Sheet({ onClose, titulo, subtitulo, children }) { const ref = useRef(null); useEffect(() => { const previous = document.activeElement; ref.current?.focus(); return () => previous?.focus?.(); }, []); return (
{ if (e.key === "Escape") onClose(); if (e.key === "Tab") { const nodes = [...ref.current.querySelectorAll('button:not(:disabled),a[href],input:not(:disabled),select,summary')].filter(x => x.getClientRects().length); if (nodes.length && e.shiftKey && (document.activeElement === nodes[0] || document.activeElement === ref.current)) { e.preventDefault(); nodes[nodes.length-1].focus(); } else if (nodes.length && !e.shiftKey && document.activeElement === nodes[nodes.length-1]) { e.preventDefault(); nodes[0].focus(); } } }} onClick={(e) => e.stopPropagation()} style={{ width: "100%", maxWidth: 560, background: "var(--bone,#F4EFE6)", borderRadius: "18px 18px 0 0", maxHeight: "88vh", overflowY: "auto", padding: "22px clamp(18px,5vw,30px) calc(env(safe-area-inset-bottom,0px) + 26px)" }}>
{titulo}
{subtitulo ? (
{subtitulo}
) :
} {children}
); } function Detalle({ prod }) { const patron = detectarPatron(prod.variante); return ( <> {patron && PATRON_TEXTS[patron] ? (

{PATRON_TEXTS[patron]}

) : null}
{[["Material tejido", prod.material], ["Acabado", prod.acabados], ["Medidas", prod.dimensiones], ["Patrón", patron], ["Familia", labelCat(prod.categoria)]] .filter(([, v]) => v && String(v).trim()).map(([k, v]) => (
{k}
{v}
))}
); } /* Módulo de DUDAS — aquí también se ayuda a armar la pieza, porque armar es parte de resolver la duda antes de la venta, no un botón aparte (y no todas las piezas son armables). Si el modelo tiene otros tejidos, aparecen; si no, simplemente no se muestra esa parte. */ function PanelDudas({ prod, onClose }) { const questions = [ ["¿Cuánto se demora?", "La fabricación es a pedido y el plazo orientativo es de unos 15 días. Confirmamos la fecha para tu pieza antes de iniciar; el tiempo de transporte se calcula aparte."], ["¿Cómo funciona el envío?", "Despachamos desde Bogotá. El costo y el tiempo de entrega dependen del destino y del tamaño de la pieza; te los confirmamos antes de cerrar el pedido."], ["¿Puedo elegir los colores?", "Sí. En «Elegir colores» puedes escoger el color del tejido y el de la estructura. Conservamos el diseño del tejido de la pieza que estás viendo y confirmamos los acabados disponibles para su material."], ["¿Puedo añadir balancines o descansabrazos?", "Puedes solicitar balancines para tu silla y descansabrazos de madera en Pavo Real desde el selector. Si tu modelo ya los incluye, aparecen marcados. El taller confirma la compatibilidad y el valor de los extras."], ]; return
{questions.map(([q,a]) =>
{q}

{a}

)}
Medidas y materiales de esta pieza
¿Tienes otra pregunta? Escríbenos ↗
; } /* ── Vista previa real de la pieza en el color elegido ────────────────────── La imagen de cada estructura viene con el tejido en MAGENTA. Ese magenta es la marca: se detecta por tono y se repinta al color pedido conservando la luminancia de cada píxel, así el brillo y la sombra de cada hilo quedan intactos y el resultado se ve como una foto real. La madera, el metal y el fondo no se tocan nunca. */ const ESTRUCTURA_CROMA = { "Pavo Real": "/assets/configurador/pavo-real.png", }; const puedeElegirColores = (p) => !!p && (/tejid|mimbre|pl[aá]stic|pvc|rat[aá]n|junco/i.test([p.material,p.variante,p.nombre].join(" ")) || !!detectarPatron(p.variante) || /^(sillas|mecedoras|sillas-bar|sofas)$/.test(p.categoria)); /* Zonas de color marcadas en el render. Cada estructura puede traer el tejido dividido en hasta tres cromas; el sistema detecta solas cuáles existen y ofrece un selector por cada una. Con una sola zona, un solo color. */ const ZONAS_CROMA = [ { id: "z1", nombre: "Color 1", h0: 280, h1: 350 }, // magenta { id: "z2", nombre: "Color 2", h0: 90, h1: 165 }, // verde { id: "z3", nombre: "Color 3", h0: 185, h1: 255 }, // azul ]; function SillaCroma({ src, colores, alto = 320, onZonas, onError }) { const lienzo = useRef(null); const base = useRef(null); const [listo, setListo] = useState(false); /* Se procesa la imagen una sola vez: qué píxel pertenece a qué zona y con qué factor de luz. Repintar después es instantáneo. */ useEffect(() => { if (!src) return; let vivo = true; setListo(false); base.current = null; const im = new window.Image(); im.crossOrigin = "anonymous"; im.onload = () => { if (!vivo) return; // Preserve native detail instead of enlarging a 420px intermediate. const esc = Math.min(1, 1672 / im.naturalHeight); const w = Math.max(1, Math.round(im.naturalWidth * esc)); const h = Math.max(1, Math.round(im.naturalHeight * esc)); const c = document.createElement("canvas"); c.width = w; c.height = h; const ctx = c.getContext("2d"); ctx.drawImage(im, 0, 0, w, h); let img; try { img = ctx.getImageData(0, 0, w, h); } catch (e) { if (onError) onError(); return; } const d = img.data; const acum = ZONAS_CROMA.map(() => ({ idx: [], L: [], sum: 0, n: 0 })); for (let i = 0; i < d.length; i += 4) { const r = d[i], g = d[i + 1], b = d[i + 2]; const M = Math.max(r, g, b), m = Math.min(r, g, b), dd = M - m; if (!dd) continue; const s = M ? dd / M : 0; if (s < 0.22) continue; let hu; if (M === r) hu = 60 * (((g - b) / dd) % 6); else if (M === g) hu = 60 * ((b - r) / dd + 2); else hu = 60 * ((r - g) / dd + 4); hu = (hu + 360) % 360; const zi = ZONAS_CROMA.findIndex(z => hu >= z.h0 && hu <= z.h1); if (zi < 0) continue; const L = 0.299 * r + 0.587 * g + 0.114 * b; acum[zi].idx.push(i); acum[zi].L.push(L); if (s > 0.45) { acum[zi].sum += L; acum[zi].n++; } } /* Solo cuentan las zonas con presencia real, no ruido de compresión. */ const minPx = (w * h) * 0.004; const zonas = []; acum.forEach((a, zi) => { if (a.idx.length < minPx) return; const Lref = a.n ? a.sum / a.n : 128; zonas.push({ def: ZONAS_CROMA[zi], idx: Int32Array.from(a.idx), k: Float32Array.from(a.L, (L) => Math.max(0.25, Math.min(1.6, L / Lref))), }); }); base.current = { w, h, img, zonas }; if (vivo) { setListo(true); if (onZonas) onZonas(zonas.length); } }; im.onerror = () => { if (vivo && onError) onError(); }; im.src = src; return () => { vivo = false; }; }, [src, alto]); /* Repinta cada zona con su color. */ useEffect(() => { const b = base.current, cv = lienzo.current; if (!listo || !b || !cv) return; const d = b.img.data; b.zonas.forEach((z, zi) => { const hex = colores[zi] || colores[0] || "#C8AB7A"; const R = parseInt(hex.slice(1, 3), 16), G = parseInt(hex.slice(3, 5), 16), B = parseInt(hex.slice(5, 7), 16); for (let j = 0; j < z.idx.length; j++) { const i = z.idx[j], k = z.k[j]; d[i] = Math.min(255, R * k); d[i + 1] = Math.min(255, G * k); d[i + 2] = Math.min(255, B * k); } }); cv.width = b.w; cv.height = b.h; cv.getContext("2d").putImageData(b.img, 0, 0); }, [colores, listo]); return ( ); } /* ── Precio real, no calculado ────────────────────────────────────────────── No se inventan diferencias ni recargos: la misma estructura ya existe en el catálogo como silla, como mecedora y con o sin brazos, cada una con su precio. Al cambiar una opción se busca esa pieza real y se usa SU precio. Si esa combinación todavía no existe, se dice honestamente que se cotiza. */ /* "Brazos" está anotado de forma inconsistente: en unas piezas va en `variante` ("+ brazos madera"), en otras en `acabados` ("Brazos en Madera"). Se revisan los tres campos hasta que el CMS unifique esto. */ const conBrazos = (p) => { if (!p) return false; const t = [p.variante, p.nombre_full, p.acabados].filter(Boolean).join(" "); return /brazo/i.test(t) && !/sin\s+brazo/i.test(t); }; /* Categorías que representan la misma pieza sentada, en sus dos formas. */ const PAR_TIPO = { sillas: "mecedoras", mecedoras: "sillas" }; function buscarSKU(base, categoria, brazos) { const pat = detectarPatron(base.variante); const cands = CATALOGO.filter(x => x.nombre === base.nombre && x.categoria === categoria && conBrazos(x) === brazos && x.precio); if (!cands.length) return null; // Prefiere la que comparta el mismo tejido; si no, la más barata de esa forma return cands.find(x => detectarPatron(x.variante) === pat) || null; } // The catalog stores only selected public codes, never internal Gestión data. function SlideArmar({ prod, activo, onPedir }) { const [library, setLibrary] = useState(null); const [failed, setFailed] = useState(false); const [retry, setRetry] = useState(0); const [tab, setTab] = useState(null); const [query, setQuery] = useState(""); const [selected, setSelected] = useState([]); const [paint, setPaint] = useState(null); const [arms, setArms] = useState(conBrazos(prod)); const [rock, setRock] = useState(prod.categoria === "mecedoras"); const pavo = /pavo real/i.test(prod.nombre); const chair = ["sillas", "mecedoras"].includes(prod.categoria); useEffect(() => { if (!activo) return; const abort = new AbortController(); setFailed(false); fetch("/materiales-publicos.php", { cache:"no-store", signal:abort.signal }) .then(r => { if (!r.ok) throw Error(); return r.json(); }) .then(d => { if (!Array.isArray(d.mimbre) || !d.mimbre.length || !Array.isArray(d.pintura)) throw Error(); setLibrary(d); }) .catch(e => { if (e.name !== "AbortError") setFailed(true); }); return () => abort.abort(); }, [activo, retry]); const norm = s => String(s).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); const list = (library?.[tab] || []).filter(x => norm(x.name).includes(norm(query))); const message = [ `Hola DAVA'S, me interesa ${prod.nombre}${prod.variante ? " · " + prod.variante : ""}.`, `Color del tejido: ${selected.length ? selected.map(x => x.name).join(", ") : "como en la foto"}.`, `Color de estructura: ${paint ? paint.name : "como en la foto"}.`, pavo ? `Descansabrazos de madera: ${arms ? "sí" : "no"}.` : null, chair ? `Balancines: ${rock ? "sí" : "no"}.` : null, "Conservar el tejido de esta silla. ¿Me confirman colores, extras compatibles, plazo y precio final?", `https://artedavas.com${prod.catSlug && prod.slug ? "/" + prod.catSlug + "/" + prod.slug : "/producto/" + prod.id.toLowerCase()}`, ].filter(Boolean).join("\n"); const toggle = t => { setTab(old => old === t ? null : t); setQuery(""); }; return

Elige el color del tejido y de la estructura. El diseño del tejido será el de esta pieza.

{[["mimbre","Color del tejido",selected.length ? selected.map(x => x.name).join(" · ") : "Como en la foto"],["pintura","Color de estructura",paint?.name || "Como en la foto"]].map(([id,label,value]) => {tab === id &&
{failed ?
No pudimos cargar las muestras.
: !library ?

Cargando muestras…

: <> setQuery(e.target.value)} />
{list.map(item => { const checked = id === "pintura" ? paint?.code === item.code : selected.some(x => x.code === item.code); return ; })}
{!list.length &&

No encontramos ese color.

}
{id === "mimbre" ? "Hasta 3 colores para consultar" : "Según el material de tu pieza"}
}
}
)}
{(pavo || chair) &&
Detalles para hacerla tuya {pavo && } {chair && }
}

Confirmaremos contigo los colores, los extras compatibles y el precio final. Las muestras pueden variar ligeramente en pantalla.

onPedir(prod)}>Consultar mi elección ↗
; } /* ── FRAME 3 · Cierre: similares en carrusel + cómo se hace + catálogo ────── */ function SlideCierre({ prod, recomendadas, activo, openFicha, goCatalog }) { const pista = useRef(null); const drag = useRef(null); const move = step => { const el=pista.current; if(el) el.scrollBy({left:step*Math.max(180,el.clientWidth*.8),behavior:window.matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'}); }; const pasos = [ ["Día 1", "Pides la pieza", "Confirmamos modelo, tejido y color. Ese mismo día entra a la fila del taller."], ["Día 2 – 12", "Se teje a mano", "Estructura, curado de la madera y tejido hilo a hilo. Nadie más está haciendo esta pieza."], ["Día 13 – 14", "Acabados", "Tensión, remates y control final. Si algo no queda, se rehace."], ["~ Día 15", "Sale del taller", "Despacho desde Bogotá a tu dirección."], ]; const anim = (i) => ({ opacity: activo ? 1 : 0, transform: activo ? "translateY(0)" : "translateY(22px)", transition: "opacity .6s cubic-bezier(.22,.9,.3,1) " + (i * 100) + "ms, transform .6s cubic-bezier(.22,.9,.3,1) " + (i * 100) + "ms", }); return (
{/* Similares — carrusel que pasa solo */}
{recomendadas.length > 0 && ( <>
Parecidas a esta

Si te gustó esta,
mira estas.

{if(e.key==="ArrowLeft"||e.key==="ArrowRight"){e.preventDefault();move(e.key==="ArrowRight"?1:-1);}}} onPointerDown={e=>{if(e.pointerType==="mouse")drag.current={x:e.clientX,left:e.currentTarget.scrollLeft,moved:false};}} onPointerMove={e=>{const d=drag.current;if(!d||e.buttons!==1)return;const dx=e.clientX-d.x;if(Math.abs(dx)>6){d.moved=true;e.currentTarget.setPointerCapture(e.pointerId);e.currentTarget.scrollLeft=d.left-dx;e.preventDefault();}}} onPointerUp={e=>{if(e.currentTarget.hasPointerCapture(e.pointerId))e.currentTarget.releasePointerCapture(e.pointerId);}} onPointerCancel={()=>{drag.current=null;}} onClickCapture={e=>{if(drag.current?.moved){e.preventDefault();e.stopPropagation();}drag.current=null;}} className="cierre-pista" style={{ ...anim(1), display: "flex", gap: 14, overflowX: "auto", scrollSnapType:"x proximity",touchAction:"pan-x pan-y",cursor:"grab",padding: "0 clamp(18px,5vw,50px) 8px" }}> {recomendadas.map((v) => ( ))}
)}
{/* El proceso — línea de tiempo, no cuadros que obliguen a desplazar */}
Hecho a pedido

Ninguna pieza está en bodega esperando. La tuya empieza a existir el día que la pides.

{pasos.map(([d, t, x], i) => (
{i < pasos.length - 1 && ( )}
{d}
{t}
{x}
))}
); } function PanelEnviar({ prod, msg, setMsg, onClose }) { const shareUrl = prod.catSlug && prod.slug ? `https://artedavas.com/${prod.catSlug}/${prod.slug}` : `https://artedavas.com/producto/${prod.id.toLowerCase()}`; return (

{MENSAJES[msg]}

{ pushEvento("enviar", prod, { etapa: "compartir", mensaje_idx: msg }); onClose(); }} style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, marginTop: 10, padding: "16px", borderRadius: 100, textDecoration: "none", background: "var(--ink,#1A1714)", color: "#F7F2EA", fontFamily: "var(--f-mono)", fontSize: 12, fontWeight: 600, letterSpacing: ".14em", textTransform: "uppercase" }}>Enviar por WhatsApp
); } function Lightbox({ data, onClose }) { const [i, setI] = useState(data.i); const media = data.media; useEffect(() => { const onKey = (e) => { if (e.key === "Escape") onClose(); if (e.key === "ArrowRight") setI(v => Math.min(v + 1, media.length - 1)); if (e.key === "ArrowLeft") setI(v => Math.max(v - 1, 0)); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [media.length]); const cur = media[i]; if (!cur || cur.type !== "img") return null; return (
{i > 0 && } e.stopPropagation()} style={{ width: "auto", height: "auto", maxWidth: "94vw", maxHeight: "92vh", borderRadius: 8 }} /> {i < media.length - 1 && }
); } window.Ficha = Ficha;