const { useState, useEffect, useRef } = React;
/* ------------ Icons (minimal, line) ------------ */
const IconPhone = () => (
);
const IconMail = () => (
);
const IconPin = () => (
);
const IconClock = () => (
);
const IconArrow = () => (
);
const IconInstagram = () => (
);
const IconBox = () => (
);
function formatPrice(value) {
return new Intl.NumberFormat("pt-PT", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0
}).format(Number(value || 0));
}
/* ------------ Tweak defaults ------------ */
const TWEAK_DEFAULS = /*EDITMODE-BEGIN*/{
"accent": "red"
}/*EDITMODE-END*/;
const ACCENTS = {
red: { hex: "#C8102E", soft: "#8A1523", label: "Red" },
muted: { hex: "#8F2A2A", soft: "#5E1A1A", label: "Muted" },
orange: { hex: "#E25822", soft: "#9A3A12", label: "Orange" }
};
/* ------------ Reveal on scroll ------------ */
function useReveal() {
useEffect(() => {
const els = document.querySelectorAll(".reveal");
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) {
e.target.classList.add("in");
io.unobserve(e.target);
}
});
}, { threshold: 0.12 });
els.forEach(el => io.observe(el));
return () => io.disconnect();
});
}
/* ------------ Nav ------------ */
function Nav({ data }) {
const showPartsLink = Boolean(data.partsShop && data.partsShop.apiUrl);
return (
);
}
/* ------------ Hero ------------ */
function Hero({ data }) {
return (
);
}
/* ------------ About ------------ */
function About({ data }) {
return (
{data.about.kicker}
Uma oficina, um ofício.
{data.about.body.map((p, i) =>
{p}
)}
{data.about.stats.map((s, i) => (
))}
);
}
/* ------------ Services ------------ */
function Services({ data }) {
return (
{data.services.kicker}
O que fazemos dentro do armazém.
{data.services.items.map((s, i) => (
[ {s.code} ]
{s.title}
{s.body}
))}
);
}
/* ------------ Parts ------------ */
function WebParts({ data }) {
const [parts, setParts] = useState([]);
const [status, setStatus] = useState("idle");
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const partsConfig = data.partsShop || {};
const apiUrl = partsConfig.apiUrl;
const pageSize = partsConfig.pageSize || 12;
useEffect(() => {
if (!apiUrl) return;
let cancelled = false;
const url = new URL(apiUrl, window.location.href);
url.searchParams.set("limit", pageSize);
url.searchParams.set("page", "1");
setStatus("loading");
setPage(1);
setHasMore(false);
fetch(url.toString())
.then(r => {
if (!r.ok) throw new Error("Parts unavailable");
return r.json();
})
.then(payload => {
if (cancelled) return;
setParts(Array.isArray(payload.items) ? payload.items : []);
setHasMore(Boolean(payload.pagination && payload.pagination.hasMore));
setStatus("ready");
})
.catch(() => {
if (cancelled) return;
setStatus("error");
});
return () => { cancelled = true; };
}, [apiUrl, pageSize]);
function loadMoreParts() {
if (!apiUrl || loadingMore || !hasMore) return;
const nextPage = page + 1;
const url = new URL(apiUrl, window.location.href);
url.searchParams.set("limit", pageSize);
url.searchParams.set("page", nextPage);
setLoadingMore(true);
fetch(url.toString())
.then(r => {
if (!r.ok) throw new Error("Parts unavailable");
return r.json();
})
.then(payload => {
const nextItems = Array.isArray(payload.items) ? payload.items : [];
setParts(current => {
const seen = new Set(current.map(item => item.id));
return current.concat(nextItems.filter(item => !seen.has(item.id)));
});
setPage(nextPage);
setHasMore(Boolean(payload.pagination && payload.pagination.hasMore));
})
.catch(() => {
setHasMore(false);
})
.finally(() => {
setLoadingMore(false);
});
}
if (!apiUrl || status === "error") {
return null;
}
return (
{partsConfig.kicker || "Peças"}
{partsConfig.title || "Peças disponíveis."}
{partsConfig.subtitle || "Peças usadas disponíveis para consulta. Liga para confirmar compatibilidade e reservar."}
{status === "loading" ? (
) : parts.length === 0 ? (
{partsConfig.emptyTitle || "Sem peças disponíveis neste momento."}
{partsConfig.emptyMessage || "Volta a consultar em breve ou liga para a oficina para saber que peças podem estar a chegar."}
{partsConfig.buttonLabel || "Ligar à oficina"}
) : (
{parts.map(part => (
{part.imageUrl ? (
) : (
)}
{part.featured &&
Destaque }
))}
)}
{status === "ready" && hasMore && (
{loadingMore ? "A carregar..." : (partsConfig.moreLabel || "Ver mais peças")}
)}
);
}
/* ------------ Contact ------------ */
function Contact({ data }) {
const todayIdx = (new Date().getDay() + 6) % 7; // 0 = Segunda
return (
);
}
/* ------------ Footer ------------ */
function Footer({ data }) {
const ig = (data.socials || []).find(s => s.name === "Instagram");
return (
);
}
/* ------------ App ------------ */
function App() {
const [data, setData] = useState(null);
const [tw, setTw] = (window.useTweaks ? window.useTweaks(TWEAK_DEFAULS) : useState(TWEAK_DEFAULS));
useEffect(() => {
document.documentElement.classList.add("app-mounted");
}, []);
// load JSON (try fetch; if sandbox blocks it, fall back to embedded data)
useEffect(() => {
fetch("content.json")
.then(r => r.json())
.then(setData)
.catch(() => {
if (window.__FALLBACK_DATA__) setData(window.__FALLBACK_DATA__);
});
}, []);
// apply accent CSS var
useEffect(() => {
const a = ACCENTS[tw.accent] || ACCENTS.red;
document.documentElement.style.setProperty("--accent", a.hex);
document.documentElement.style.setProperty("--accent-soft", a.soft);
}, [tw.accent]);
useReveal();
if (!data) {
return (
Loading · Dom Serafim
);
}
const Tweaks = window.TweaksPanel;
const TweakSection = window.TweakSection;
const TweakRadio = window.TweakRadio;
return (
{Tweaks && (
setTw({ ...tw, accent: v })}
/>
{Object.entries(ACCENTS).map(([k, v]) => (
setTw({ ...tw, accent: k })}/>
))}
)}
);
}
/* Fallback data embed — used if fetch is blocked by sandbox */
window.__FALLBACK_DATA__ = null;
fetch("content.json").then(r => r.text()).then(t => {
try { window.__FALLBACK_DATA__ = JSON.parse(t); } catch (e) {}
});
ReactDOM.createRoot(document.getElementById("app")).render(
);