/* global React */
(function () {
const { useState, useEffect } = React;
// ============ Router (hash-based) ============
function useRoute() {
const get = () => (location.hash.replace(/^#\/?/, '') || 'home').split('?')[0];
const [route, setRoute] = useState(get);
useEffect(() => {
const on = () => { setRoute(get()); window.scrollTo({ top: 0, behavior: 'instant' }); };
window.addEventListener('hashchange', on);
return () => window.removeEventListener('hashchange', on);
}, []);
return route;
}
function navigate(route) {
location.hash = '/' + route;
}
// ============ Theme ============
function useTheme() {
const [theme, setTheme] = useState(() => {
try { return localStorage.getItem('pc-theme') || 'light'; } catch { return 'light'; }
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('pc-theme', theme); } catch {}
}, [theme]);
return [theme, setTheme];
}
// ============ Nav ============
function Nav({ route }) {
const [theme, setTheme] = window.useTheme();
const items = [
{ id: 'home', label: 'Início' },
{ id: 'servicos', label: 'Serviços' },
{ id: 'sobre', label: 'Sobre' },
{ id: 'contato', label: 'Contato' },
];
return (
);
}
// ============ Footer ============
function Footer() {
return (
);
}
// ============ Reveal-on-scroll wrapper ============
function Reveal({ children, delay = 0, as: As = 'div', className = '', ...rest }) {
const ref = React.useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
setTimeout(() => e.target.classList.add('in'), delay);
obs.unobserve(e.target);
}
});
}, { threshold: 0.12 });
obs.observe(el);
return () => obs.disconnect();
}, [delay]);
return {children};
}
// ============ Placeholder image ============
function Placeholder({ label, height = '100%', stripe = 'diag', tone = 'ink' }) {
// tone: 'ink' (neutral) or 'orange'
const stripeColor = tone === 'orange'
? 'color-mix(in oklab, var(--orange) 22%, transparent)'
: 'color-mix(in oklab, var(--ink) 8%, transparent)';
const bgTint = tone === 'orange'
? 'color-mix(in oklab, var(--orange) 6%, var(--bg-elev))'
: 'color-mix(in oklab, var(--ink) 3%, var(--bg-elev))';
const angle = stripe === 'reverse' ? 45 : 135;
return (
{label}
);
}
// ============ Animated counter ============
function Counter({ value, suffix = '', prefix = '', duration = 1600 }) {
const ref = React.useRef(null);
const [shown, setShown] = useState('0');
useEffect(() => {
const el = ref.current;
if (!el) return;
let started = false;
const obs = new IntersectionObserver((entries) => {
if (started || !entries[0].isIntersecting) return;
started = true;
const start = performance.now();
const isFloat = /\./.test(String(value));
const target = parseFloat(value);
function tick(now) {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
const cur = target * eased;
setShown(isFloat ? cur.toFixed(1) : Math.round(cur).toLocaleString('pt-BR'));
if (t < 1) requestAnimationFrame(tick);
else setShown(value.toLocaleString ? value.toLocaleString('pt-BR') : String(value));
}
requestAnimationFrame(tick);
}, { threshold: 0.4 });
obs.observe(el);
return () => obs.disconnect();
}, [value, duration]);
return {prefix}{shown}{suffix};
}
// ============ BIM Comparator (real drag) ============
function Compare({ heightPx = 520 }) {
const ref = React.useRef(null);
const imgRef = React.useRef(null);
const [pos, setPos] = useState(100);
const dragging = React.useRef(false);
const animated = React.useRef(false);
const minPosRef = React.useRef(0);
// Calculates the minimum bar position: the point where the right image
// exactly fills the visible area with no striped background showing.
const calcMinPos = () => {
const container = ref.current;
const img = imgRef.current;
if (!container || !img || !img.naturalWidth) return;
const cw = container.getBoundingClientRect().width;
const imgAspect = img.naturalWidth / img.naturalHeight;
const boxAspect = cw / heightPx;
const renderedW = imgAspect <= boxAspect ? heightPx * imgAspect : cw;
const newMin = Math.max(0, (1 - renderedW / cw) * 100);
minPosRef.current = newMin;
setPos(p => Math.max(p, newMin));
};
const move = (clientX) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const p = Math.max(minPosRef.current, Math.min(100, ((clientX - r.left) / r.width) * 100));
setPos(p);
};
// Auto-animate on first viewport entry to hint interactivity
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver((entries) => {
if (animated.current || !entries[0].isIntersecting) return;
animated.current = true;
obs.unobserve(el);
// Animate within the valid range [minPos, 100]
// Targets as fractions of the range above minPos
const rawTargets = [0.05, 0.65, 0.4];
const durations = [1100, 950, 800];
let cur = 100;
let step = 0;
function runStep() {
if (step >= rawTargets.length || dragging.current) return;
const range = 100 - minPosRef.current;
const target = minPosRef.current + rawTargets[step] * range;
const duration = durations[step];
const from = cur;
const t0 = performance.now();
function frame(now) {
if (dragging.current) return;
const t = Math.min(1, (now - t0) / duration);
const e = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
cur = from + (target - from) * e;
setPos(cur);
if (t < 1) requestAnimationFrame(frame);
else { cur = target; setPos(target); step++; setTimeout(runStep, 200); }
}
requestAnimationFrame(frame);
}
setTimeout(runStep, 700);
}, { threshold: 0.35 });
obs.observe(el);
return () => obs.disconnect();
}, []);
useEffect(() => {
window.addEventListener('resize', calcMinPos);
return () => window.removeEventListener('resize', calcMinPos);
}, []);
useEffect(() => {
const onMove = (e) => { if (dragging.current) move(e.clientX ?? (e.touches?.[0]?.clientX)); };
const onUp = () => { dragging.current = false; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
window.addEventListener('touchmove', onMove);
window.addEventListener('touchend', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
window.removeEventListener('touchmove', onMove);
window.removeEventListener('touchend', onUp);
};
}, []);
return (
{ dragging.current = true; move(e.clientX); }}
onTouchStart={(e) => { dragging.current = true; move(e.touches[0].clientX); }}
style={{ height: heightPx }}
>
Modelo 3D · BIM
Render arquitetônico
Quantitativos extraídos
→ Planilha orçamentária
arraste ← →
);
}
// ============ Cookie banner (LGPD) ============
function CookieBanner() {
const [shown, setShown] = useState(false);
const [expanded, setExpanded] = useState(false);
const [prefs, setPrefs] = useState({ necessary: true, analytics: false });
useEffect(() => {
let stored;
try { stored = localStorage.getItem('pc-cookies'); } catch {}
if (!stored) setShown(true);
}, []);
const persist = (choice) => {
try { localStorage.setItem('pc-cookies', JSON.stringify({ ...choice, ts: Date.now() })); } catch {}
setShown(false);
};
if (!shown) return null;
return (
{expanded && (
)}
{!expanded ? (
<>
>
) : (
<>
>
)}
);
}
// ============ WhatsApp button ============
function WhatsAppButton() {
const phone = '5531988817140';
const message = encodeURIComponent('Olá! Gostaria de solicitar um orçamento.');
return (
{ e.currentTarget.style.transform = 'scale(1.1)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(0,0,0,0.24)'; }}
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.18)'; }}
>
);
}
// Expose everything
Object.assign(window, { useRoute, navigate, useTheme, Nav, Footer, Reveal, Placeholder, Counter, Compare, CookieBanner, WhatsAppButton });
})();