// aero-shell.jsx — Site chrome: top nav, observation ticker, footer,
// hash-based router. Exposes window.AeroShell, useRoute().
const { useState, useEffect, useMemo, useRef } = React;
// ─────────────────────────────────────────────────────────────────────
// Tokens — site-wide design system, neutral warm cream chrome.
// Page-level palettes (atmospheric, weather-driven) sit INSIDE this.
// ─────────────────────────────────────────────────────────────────────
const AERO = {
paper: '#f3eee2', // canvas
paperDeep: '#e8e1cf',
ink: '#1b1a17',
inkDim: '#4b4940',
inkSoft: '#7e7a6a',
hair: 'rgba(27,26,23,0.14)',
line: 'rgba(27,26,23,0.32)',
accent: '#3a4f6b',
warm: '#b86a2c',
danger: '#a8463a',
ok: '#5e7c4a',
};
// Inject base CSS once.
(function () {
if (document.getElementById('aero-base')) return;
const s = document.createElement('style');
s.id = 'aero-base';
s.textContent = `
html, body { background: ${AERO.paper}; color: ${AERO.ink}; margin: 0; padding: 0; }
body {
font-family: 'Geist', -apple-system, system-ui, sans-serif;
-webkit-font-smoothing: antialiased; font-feature-settings: 'ss01';
}
*::-webkit-scrollbar { width: 6px; height: 6px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.18); border-radius: 3px; }
a { color: inherit; text-decoration: none; }
.aero-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
.aero-serif { font-family: 'Instrument Serif', 'Iowan Old Style', serif; }
.aero-eyebrow {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 1.6px; text-transform: uppercase;
color: ${AERO.inkSoft};
}
.aero-link { transition: color .15s; }
.aero-link:hover { color: ${AERO.warm}; }
/* selection */
::selection { background: ${AERO.warm}; color: ${AERO.paper}; }
`;
document.head.appendChild(s);
})();
// ─────────────────────────────────────────────────────────────────────
// Hash router — #/home, #/city/smr, #/radar, #/alerts, #/api
// ─────────────────────────────────────────────────────────────────────
function parseHash(h) {
const raw = (h || "").replace(/^#\/?/, "");
if (!raw) return { name: "home", params: {} };
const [name, ...rest] = raw.split("/");
return { name, params: { id: rest[0] } };
}
function useRoute() {
const [route, setRoute] = useState(() => parseHash(window.location.hash));
useEffect(() => {
const onChange = () => setRoute(parseHash(window.location.hash));
window.addEventListener("hashchange", onChange);
return () => window.removeEventListener("hashchange", onChange);
}, []);
return route;
}
function navigate(path) { window.location.hash = path; }
// ─────────────────────────────────────────────────────────────────────
// Logo wordmark — bespoke, draws an isobar through the AEROGRAMA letters
// ─────────────────────────────────────────────────────────────────────
function Wordmark({ size = 22, color = AERO.ink }) {
return (
{/* concentric isobars */}
AEROGRAMA
);
}
// ─────────────────────────────────────────────────────────────────────
// Top navigation
// ─────────────────────────────────────────────────────────────────────
function TopNav({ lang, setLang }) {
const route = useRoute();
const items = lang === "pt"
? [
["home", "Início"],
["city/smr", "Previsão"],
["radar", "Radar"],
["alerts", "Alertas"],
["api", "API"],
]
: [
["home", "Home"],
["city/smr", "Forecast"],
["radar", "Radar"],
["alerts", "Alerts"],
["api", "API"],
];
const active = (slug) => {
const base = slug.split("/")[0];
return route.name === base || (base === "home" && (route.name === "" || !route.name));
};
return (
{items.map(([slug, label]) => (
{label}
))}
setLang(lang === "pt" ? "en" : "pt")} style={{
background: 'transparent', border: `0.5px solid ${AERO.line}`,
borderRadius: 999, padding: '5px 12px',
fontFamily: "'JetBrains Mono', monospace", fontSize: 11,
letterSpacing: 1, color: AERO.ink, cursor: 'pointer',
}}>
{lang === "pt" ? "PT / EN" : "EN / PT"}
{lang === "pt" ? "Acessar API" : "Get API"}
);
}
// ─────────────────────────────────────────────────────────────────────
// Live observation ticker — slim strip under the nav, current conditions
// at featured cities, scrolls horizontally on hover (decorative only).
// ─────────────────────────────────────────────────────────────────────
function ObservationTicker({ lang }) {
const t = window.WX.T[lang];
const defaultItems = [
{ city: "Santa Maria", temp: "14.4°", pt: "Garoa intensa", en: "Dense drizzle" },
{ city: "Porto Alegre", temp: "15.1°", pt: "Chuva fraca", en: "Light rain" },
{ city: "São Paulo", temp: "19.8°", pt: "Nublado", en: "Overcast" },
{ city: "Rio de Janeiro", temp: "24.6°", pt: "Parc. nublado", en: "Partly cloudy" },
{ city: "Brasília", temp: "21.2°", pt: "Quase limpo", en: "Mainly clear" },
{ city: "Manaus", temp: "28.4°", pt: "Chuva", en: "Rain" },
{ city: "Recife", temp: "27.0°", pt: "Parc. nublado", en: "Partly cloudy" },
{ city: "Lisboa", temp: "16.8°", pt: "Quase limpo", en: "Mainly clear" },
{ city: "London", temp: "9.3°", pt: "Neblina", en: "Fog" },
{ city: "Tokyo", temp: "22.1°", pt: "Limpo", en: "Clear" },
];
const [items, setItems] = useState(defaultItems);
useEffect(() => {
let alive = true;
const fetchTicker = async () => {
try {
const toFetch = window.WX.CITIES.slice(0, 10);
// Fallback to default cities if CITIES array is short
const results = await Promise.all(
toFetch.map(c => window.WX.fetchWeather(c.lat, c.lon, 1).catch(() => null))
);
if (!alive) return;
const newItems = results.map((res, i) => {
if (!res || !res.current_complete) return defaultItems[i];
const cur = res.current_complete;
return {
city: toFetch[i].name,
temp: cur.temperature_2m.toFixed(1) + "°",
pt: cur.weathercode_desc.pt,
en: cur.weathercode_desc.en
};
});
setItems(newItems);
} catch (e) {
console.error(e);
}
};
fetchTicker();
return () => { alive = false; };
}, []);
return (
● {lang === "pt" ? "AO VIVO" : "LIVE"}
{items.map((it, i) => (
{it.city}
{it.temp}
{lang === "pt" ? it.pt : it.en}
))}
{lang === "pt" ? "Atualizado" : "Updated"} {new Date().toLocaleTimeString(lang === "pt" ? "pt-BR" : "en-US", { hour: '2-digit', minute: '2-digit' })} GMT-3
);
}
// ─────────────────────────────────────────────────────────────────────
// Footer
// ─────────────────────────────────────────────────────────────────────
function Footer({ lang }) {
const cols = lang === "pt"
? [
["Produto", ["Início", "Previsão", "Radar", "Alertas", "Estações"]],
["Plataforma", ["API REST", "Documentação", "Status", "Limites", "Webhooks"]],
["Dados", ["Fontes & licenças", "Modelos numéricos", "Estações INMET", "Imagens GOES-16", "Histórico"]],
["Empresa", ["Sobre", "Equipe", "Contato", "Imprensa", "Carreiras"]],
]
: [
["Product", ["Home", "Forecast", "Radar", "Alerts", "Stations"]],
["Platform", ["REST API", "Documentation", "Status", "Limits", "Webhooks"]],
["Data", ["Sources & licenses", "Numerical models", "INMET stations", "GOES-16 imagery", "Archive"]],
["Company", ["About", "Team", "Contact", "Press", "Careers"]],
];
return (
);
}
// ─────────────────────────────────────────────────────────────────────
// Page Shell — wraps the active page with chrome
// ─────────────────────────────────────────────────────────────────────
function AeroShell({ children, lang, setLang }) {
return (
{children}
);
}
// ─────────────────────────────────────────────────────────────────────
// Common building blocks
// ─────────────────────────────────────────────────────────────────────
function Container({ children, style = {} }) {
return (
{children}
);
}
function SectionEyebrow({ children, hint }) {
return (
{children}
{hint &&
{hint}
}
);
}
function TinyChart({ values, width, height, color, fill, smooth = true, strokeWidth = 1.5 }) {
// re-uses Sparkline from weather-vis.jsx
return (
);
}
function CookieConsent({ lang }) {
const [show, setShow] = useState(false);
useEffect(() => {
if (!localStorage.getItem('aerograma_consent')) {
setShow(true);
}
}, []);
const handleAccept = () => {
localStorage.setItem('aerograma_consent', 'granted');
setShow(false);
window.dispatchEvent(new Event('aerograma_consent_granted'));
};
const handleDecline = () => {
localStorage.setItem('aerograma_consent', 'denied');
setShow(false);
};
if (!show) return null;
return (
{lang === 'pt'
? "Usamos cookies e localização para personalizar a previsão do tempo para a sua região."
: "We use cookies and location to personalize the weather forecast for your area."}
{lang === 'pt' ? 'Recusar' : 'Decline'}
{lang === 'pt' ? 'Aceitar' : 'Accept'}
);
}
Object.assign(window, {
AERO, AeroShell, TopNav, ObservationTicker, Footer, Wordmark,
Container, SectionEyebrow, TinyChart, CookieConsent, useRoute, navigate,
});