// JOXE ASESORES DE IMAGEN — Componentes
// Paleta: noir, ivory, bronze
const Placeholder = ({ label, ratio = "4/5", tone = "ivory", note }) => {
const bg = tone === "noir" ? "#141212" : "#EDE6DA";
const fg = tone === "noir" ? "rgba(230,220,200,0.55)" : "rgba(20,18,18,0.55)";
const stripe = tone === "noir" ? "rgba(230,220,200,0.06)" : "rgba(20,18,18,0.05)";
return (
placeholder
{label}
{note &&
{note}
}
);
};
const Mono = ({ children, style }) => (
{children}
);
// ——————————————————————————————————————————————
// NAV
// ——————————————————————————————————————————————
const Nav = ({ onReserveClick, scrolled }) => {
const [open, setOpen] = React.useState(false);
return (
<>
{open && (
{[
["Servicios", "#servicios"],
["Galería", "#galeria"],
["Ubicación", "#ubicacion"],
].map(([label, href]) => (
setOpen(false)} style={{
color: "var(--ivory)", textDecoration: "none",
fontFamily: "var(--display)", fontSize: 34, letterSpacing: "0.02em",
}}>{label}
))}
setOpen(false)} style={{
color: "var(--bronze)", textDecoration: "none",
fontFamily: "var(--sans)", fontSize: 15, letterSpacing: "0.2em",
textTransform: "uppercase",
}}>Check-In →
)}
>
);
};
// ——————————————————————————————————————————————
// HERO
// ——————————————————————————————————————————————
const Hero = ({ onReserveClick }) => (
Soacha · San Mateo
La imagen
no se improvisa.
Se diseña.
Un espacio donde el corte, el color y la asesoría se trabajan
con precisión. Atención personalizada, sin apuros, sin plantillas.
Trabajo reciente
Color correction + corte · 3.5 hrs
);
// ——————————————————————————————————————————————
// MARQUEE
// ——————————————————————————————————————————————
const Marquee = () => {
const items = ["Corte", "Color", "Balayage", "Keratina", "Asesoría de imagen", "Novias", "Extensiones", "Peinados"];
return (
{[...items, ...items, ...items].map((item, i) => (
{item}
✦
))}
);
};
// ——————————————————————————————————————————————
// SERVICIOS
// ——————————————————————————————————————————————
const formatPrice = (price, note) => {
const formatted = "$" + Number(price).toLocaleString("es-CO");
return note ? `${note} ${formatted}` : formatted;
};
const formatDur = (mins) => {
if (!mins) return "";
if (mins >= 60 && mins % 60 === 0) return `${mins / 60} hrs`;
if (mins > 60) return `${(mins / 60).toFixed(1).replace(".0", "")} hrs`;
return `${mins} min`;
};
const FALLBACK_SERVICES = [
{ id:"s1", name:"Corte mujer", price:85000, dur:60, active:true },
{ id:"s2", name:"Corte hombre", price:45000, dur:40, active:true },
{ id:"s3", name:"Balayage", price:280000, dur:180, active:true, note:"desde" },
{ id:"s4", name:"Color correction", price:320000, dur:240, active:true, note:"desde" },
{ id:"s5", name:"Color raíz", price:120000, dur:90, active:true },
{ id:"s6", name:"Keratina", price:260000, dur:180, active:true, note:"desde" },
{ id:"s7", name:"Asesoría de imagen", price:180000, dur:90, active:true },
{ id:"s8", name:"Peinado novia", price:220000, dur:120, active:true, note:"desde" },
];
const Services = () => {
const [services, setServices] = React.useState(FALLBACK_SERVICES);
React.useEffect(() => {
fetch("/api/catalog")
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { if (data.services?.length) setServices(data.services); })
.catch(() => {});
}, []);
return (
01 — Servicios
Precios claros.
Sin sorpresas.
La consulta inicial siempre es gratis. Te contamos qué
necesita tu cabello antes de tocarlo.
Los precios pueden variar según largo, densidad y estado del cabello.
Te confirmamos el valor exacto en la consulta.
{services.map((item, i) => (
{item.name}
{item.dur > 0 && (
{formatDur(item.dur)}
)}
{formatPrice(item.price, item.note)}
))}
);
};
// ——————————————————————————————————————————————
// ANTES / DESPUÉS
// ——————————————————————————————————————————————
const BeforeAfter = () => {
const [pos, setPos] = React.useState(50);
return (
{
if (e.buttons !== 1) return;
const rect = e.currentTarget.getBoundingClientRect();
const p = ((e.clientX - rect.left) / rect.width) * 100;
setPos(Math.max(0, Math.min(100, p)));
}}
onTouchMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const p = ((e.touches[0].clientX - rect.left) / rect.width) * 100;
setPos(Math.max(0, Math.min(100, p)));
}}
>
Antes
Después
);
};
// ——————————————————————————————————————————————
// GALERÍA
// ——————————————————————————————————————————————
const Gallery = () => {
const [idx, setIdx] = React.useState(0);
const cases = [
{ title: "Balayage miel sobre base oscura", meta: "Color correction · 4 hrs" },
{ title: "Corte bob francés", meta: "Corte + styling · 90 min" },
{ title: "Recuperación post-decoloración", meta: "Tratamiento + color · 3.5 hrs" },
{ title: "Rubio platino", meta: "Decoloración + matiz · 5 hrs" },
];
return (
02 — Trabajos reales
El resultado habla.
Arrastra para ver.
Caso {String(idx + 1).padStart(2, "0")} / {String(cases.length).padStart(2, "0")}
{cases[idx].title}
Cada trabajo parte de un diagnóstico honesto del cabello.
Nunca prometemos lo que no podemos sostener.
Servicio
{cases[idx].meta}
{cases.map((_, i) => (
);
};
// ——————————————————————————————————————————————
// TESTIMONIOS
// ——————————————————————————————————————————————
// ——————————————————————————————————————————————
// MAPA DE UBICACIÓN
// ——————————————————————————————————————————————
const LocationMap = () => (
);
// ——————————————————————————————————————————————
// FOOTER
// ——————————————————————————————————————————————
const Footer = () => (
);
// ——————————————————————————————————————————————
// WHATSAPP BLOB
// ——————————————————————————————————————————————
const WA_DEFAULT_NUMBER = "573124499862";
const WA_DEFAULT_MSG = "Escríbenos";
const WA_DEFAULT_CHAT_MSG = "";
const getWAConfig = () => {
try {
const stored = JSON.parse(localStorage.getItem("joxe_admin_v1") || "{}");
return {
number: stored.whatsappNumber || WA_DEFAULT_NUMBER,
msg: stored.whatsappMsg || WA_DEFAULT_MSG,
chatMsg: stored.whatsappChatMsg ?? WA_DEFAULT_CHAT_MSG,
};
} catch { return { number: WA_DEFAULT_NUMBER, msg: WA_DEFAULT_MSG, chatMsg: WA_DEFAULT_CHAT_MSG }; }
};
const WhatsAppBlob = () => {
const [hovered, setHovered] = React.useState(false);
const cfg = getWAConfig();
const url = cfg.chatMsg
? `https://wa.me/${cfg.number}?text=${encodeURIComponent(cfg.chatMsg)}`
: `https://wa.me/${cfg.number}`;
return (
setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
position: "fixed", bottom: 28, right: 28, zIndex: 9999,
display: "flex", alignItems: "center", gap: 10,
background: "#25D366", color: "#fff",
borderRadius: 999, textDecoration: "none",
padding: hovered ? "14px 22px 14px 18px" : "14px",
boxShadow: "0 4px 24px rgba(37,211,102,0.35)",
transition: "all 0.25s cubic-bezier(.4,0,.2,1)",
overflow: "hidden", whiteSpace: "nowrap",
}}
>
{hovered && (
{cfg.msg}
)}
);
};
Object.assign(window, {
Nav, Hero, Marquee, Services, Gallery,
LocationMap, Footer, Placeholder, Mono,
WhatsAppBlob,
});