// Shared primitives: icon system (minimal line SVGs), container helpers, hooks.
const { useState, useEffect, useRef, useMemo, useCallback } = React;
// --- Tweak hook: reads window.__TWEAKS__, listens for host edit-mode messages.
function useTweaks() {
const [tweaks, setTweaks] = useState(() => ({ ...window.__TWEAKS__ }));
useEffect(() => {
let tweaksPanelOpen = false;
const onMsg = (e) => {
const d = e.data || {};
if (d.type === '__activate_edit_mode') { window.__setTweaksOpen && window.__setTweaksOpen(true); }
if (d.type === '__deactivate_edit_mode') { window.__setTweaksOpen && window.__setTweaksOpen(false); }
};
window.addEventListener('message', onMsg);
// Announce availability AFTER listener is wired
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const update = useCallback((patch) => {
setTweaks(prev => {
const next = { ...prev, ...patch };
window.__TWEAKS__ = next;
try { window.parent.postMessage({ type: '__edit_mode_set_keys', edits: patch }, '*'); } catch (_) {}
return next;
});
}, []);
return [tweaks, update];
}
// --- Simple in-view observer for reveal animations
function useInView(options = {}) {
const ref = useRef(null);
const [inView, setInView] = useState(false);
useEffect(() => {
if (!ref.current) return;
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) { setInView(true); io.disconnect(); }
}, { threshold: 0.12, ...options });
io.observe(ref.current);
return () => io.disconnect();
}, []);
return [ref, inView];
}
// --- Reveal wrapper — fade + translate on scroll
function Reveal({ children, delay = 0, y = 24, as: Tag = 'div', style, ...rest }) {
const [ref, inView] = useInView();
return (
{children}
);
}
// --- Counter that animates on first view
function CountUp({ to, suffix = '', prefix = '', duration = 1400, children }) {
const [ref, inView] = useInView({ threshold: 0.3 });
const [val, setVal] = useState(0);
useEffect(() => {
if (!inView) return;
let raf; const start = performance.now();
const tick = (t) => {
const p = Math.min(1, (t - start) / duration);
const eased = 1 - Math.pow(1 - p, 3);
setVal(to * eased);
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [inView, to, duration]);
const display = to >= 100 ? Math.round(val) : val.toFixed(val < 10 && to < 10 ? 1 : 0);
return {prefix}{display}{suffix}{children};
}
// --- Parallax hook — returns translateY value based on scroll relative to element
function useParallax(strength = 0.15) {
const ref = useRef(null);
const [offset, setOffset] = useState(0);
useEffect(() => {
const onScroll = () => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const vh = window.innerHeight;
const center = rect.top + rect.height / 2;
setOffset((center - vh / 2) * -strength);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, [strength]);
return [ref, offset];
}
// --- Icon set — minimal line icons, 24×24 grid, stroke 1.5
const Icon = {
Carpet: (p) => (
),
Tile: (p) => (
),
Air: (p) => (
),
Drop: (p) => (
),
Building: (p) => (
),
Phone: (p) => (
),
Mail: (p) => (
),
Pin: (p) => (
),
Chevron: (p) => (
),
ChevronDown: (p) => (
),
Plus: (p) => (
),
Minus: (p) => (
),
Check: (p) => (
),
Close: (p) => (
),
};
// Small chevron link used across tiles
function ChevLink({ href = "#", children, color }) {
return (
{children}
);
}
Object.assign(window, { useTweaks, useInView, Reveal, CountUp, useParallax, Icon, ChevLink });