// aero-home.jsx — Landing page
function PageHome({ lang, setLang }) {
const t = window.WX.T[lang];
const [data, setData] = useState(window.WX.DATA);
const [locationName, setLocationName] = useState("Santa Maria, RS");
const [coords, setCoords] = useState({ lat: -29.69, lon: -53.84, alt: 77 });
useEffect(() => {
let alive = true;
const fetchLocationAndWeather = async () => {
try {
if (localStorage.getItem('aerograma_consent') !== 'granted') {
window.WX.fetchWeather(-29.69, -53.84, 7).then(j => alive && setData(j));
return;
}
navigator.geolocation.getCurrentPosition(async (pos) => {
const { latitude: lat, longitude: lon, altitude: alt } = pos.coords;
const roundedLat = Math.round(lat * 100) / 100;
const roundedLon = Math.round(lon * 100) / 100;
if (alive) setCoords({ lat: roundedLat, lon: roundedLon, alt: alt ? Math.round(alt) : 0 });
try {
const res = await fetch(`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=${lang}`);
const geo = await res.json();
if (alive && geo.city) {
setLocationName(`${geo.city}, ${geo.principalSubdivision || geo.countryCode}`);
} else if (alive) {
setLocationName(lang === "pt" ? "Sua Localização" : "Your Location");
}
} catch (e) {
if (alive) setLocationName(lang === "pt" ? "Sua Localização" : "Your Location");
}
const wData = await window.WX.fetchWeather(lat, lon, 7);
if (alive) setData(wData);
}, () => {
// Fallback if denied
window.WX.fetchWeather(-29.69, -53.84, 7).then(j => alive && setData(j));
}, { timeout: 10000 });
} catch (e) {
window.WX.fetchWeather(-29.69, -53.84, 7).then(j => alive && setData(j));
}
};
fetchLocationAndWeather();
const onConsent = () => fetchLocationAndWeather();
window.addEventListener('aerograma_consent_granted', onConsent);
return () => {
alive = false;
window.removeEventListener('aerograma_consent_granted', onConsent);
};
}, [lang]);
const [inmetAlerts, setInmetAlerts] = useState(null); // null = loading
const [alertFilter, setAlertFilter] = useState('Todos'); // 'Todos', 'Regional', 'Nacional', 'Internacional'
const cur = data.current_complete || window.WX.DATA.current_complete;
const today = (data.forecast && data.forecast[0]) ? data.forecast[0] : window.WX.DATA.forecast[0];
const hour = window.WX.hourOf(cur.time);
const palette = window.WX.palette(cur.weathercode, hour);
useEffect(() => {
let alive = true;
window.WX.fetchInmetAlerts().then(res => {
if (alive) {
// scope vem real do INMET via api.aerograma.pro/v1/alerts
setInmetAlerts(res.alerts || []);
}
});
return () => { alive = false; };
}, []);
const updatedTime = new Date().toLocaleTimeString(lang === "pt" ? "pt-BR" : "en-US", { hour: '2-digit', minute: '2-digit' });
// Map INMET level to HomeAlert level prop
function inmetLevel(al) {
const lev = (al.level || '').toLowerCase();
if (lev === 'extreme') return 'warning';
if (lev === 'warning') return 'warning';
if (lev === 'watch') return 'watch';
return 'advisory';
}
// Format expiry from ISO or "DD/MM/YYYY HH:mm" strings
function fmtExpiry(str) {
if (!str) return '';
const m = str.match(/(\d{2})[\/\-](\d{2})[\/\-](\d{4})\s+(\d{2}:\d{2})/);
if (m) return `${m[2]}/${m[1]} ${m[4]}`;
const m2 = str.match(/(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
if (m2) return `${m2[3]}/${m2[2]} ${m2[4]}`;
return str.slice(0, 16);
}
const filteredAlerts = React.useMemo(() => {
if (!inmetAlerts) return null;
if (alertFilter === 'Todos') return inmetAlerts;
return inmetAlerts.filter(al => (al.scope || 'Regional') === alertFilter);
}, [inmetAlerts, alertFilter]);
return (
{/* ─── HERO ──────────────────────────────────────────────────── */}
{/* ─── ACTIVE ALERTS BANNER ─────────────────────────────────── */}
{lang === "pt" ? "Alertas ativos" : "Active alerts"}
{inmetAlerts === null ? (
/* Loading skeleton */
) : inmetAlerts.length > 0 ? (
<>
{['Todos', 'Regional', 'Nacional', 'Internacional'].map(f => (
setAlertFilter(f)} style={{
padding: '6px 14px', borderRadius: 16, border: `1px solid ${alertFilter === f ? AERO.ink : AERO.line}`,
background: alertFilter === f ? AERO.ink : 'transparent',
color: alertFilter === f ? AERO.bg0 : AERO.ink,
fontFamily: "'JetBrains Mono', monospace", fontSize: 11, cursor: 'pointer',
transition: 'all 0.2s'
}}>
{f}
))}
{filteredAlerts.length > 0 ? (
{filteredAlerts.slice(0, 6).map((al, idx) => (
))}
) : (
{lang === "pt"
? `Nenhum alerta ${alertFilter.toLowerCase()} em vigor.`
: `No ${alertFilter.toLowerCase()} alerts in effect.`}
)}
>
) : (
{lang === "pt"
? "Nenhum alerta meteorológico em vigor · fonte: INMET"
: "No weather alerts in effect · source: INMET"}
)}
{/* ─── FEATURED CITIES ──────────────────────────────────────── */}
{lang === "pt" ? "Observações em destaque" : "Featured observations"}
{/* ─── MINI RADAR ───────────────────────────────────────────── */}
{lang === "pt" ? "Radar composto · Brasil" : "Composite radar · Brazil"}
{/* ─── EDITORIAL / ANALYSIS ─────────────────────────────────── */}
{lang === "pt" ? "Boletim do dia" : "Today's bulletin"}
{/* ─── STATS STRIP ──────────────────────────────────────────── */}
);
}
// ─────────────────────────────────────────────────────────────────────
// HERO — full-bleed atmospheric backdrop, gigantic editorial type
// ─────────────────────────────────────────────────────────────────────
function HeroBlock({ palette, cur, data, lang, t, locationName = "Santa Maria, RS", coords = { lat: -29.69, lon: -53.84, alt: 77 } }) {
const today = data.forecast[0];
return (
{/* paper wash so the cream chrome bleeds in */}
{lang === "pt" ? "Em destaque agora" : "Featured now"} · {locationName}
{cur.time.slice(11)} {data.timezone_abbr || Intl.DateTimeFormat().resolvedOptions().timeZone} · {Math.abs(coords.lat)}°{coords.lat >= 0 ? 'N' : 'S'}, {Math.abs(coords.lon)}°{coords.lon >= 0 ? 'E' : 'W'} · {coords.alt}m
{(() => {
const code = cur.weathercode;
const desc = cur.weathercode_desc[lang];
if (lang === "pt") {
if (code <= 3) return `${desc} e tempo estável no Centro do Estado.`;
if (code >= 45 && code <= 48) return `Baixa visibilidade com ${desc.toLowerCase()} na região.`;
if (code >= 51 && code <= 67) return `${desc} contínua cobre o Centro do Estado.`;
if (code >= 71 && code <= 77) return `Condições de ${desc.toLowerCase()} na serra e planalto.`;
if (code >= 80 && code <= 82) return `Instabilidade traz pancadas de chuva isoladas.`;
if (code >= 95) return `Alerta de tempestade severa se aproximando.`;
return `${desc} no momento.`;
} else {
if (code <= 3) return `${desc} and stable weather in the central region.`;
if (code >= 45 && code <= 48) return `Low visibility with ${desc.toLowerCase()} in the area.`;
if (code >= 51 && code <= 67) return `Continuous ${desc.toLowerCase()} covers the region.`;
if (code >= 71 && code <= 77) return `${desc} conditions over the highlands.`;
if (code >= 80 && code <= 82) return `Instability brings isolated showers.`;
if (code >= 95) return `Severe thunderstorm warning in effect.`;
return `${desc} at the moment.`;
}
})()}
{/* Live numbers row */}
{t.now_label} · {cur.weathercode_desc[lang]}
{Math.round(cur.temperature_2m)}°
{t.feels_like.toUpperCase()} {cur.apparent_temperature.toFixed(1)}° ·
{" "}{t.min.toUpperCase()} {Math.round(today.temperature_2m_min)}° /
{" "}{t.max.toUpperCase()} {Math.round(today.temperature_2m_max)}°
{/* CTA row */}
);
}
function HeroStat({ label, value, unit, sub, trend, palette }) {
return (
{label}
{value}
{unit && {unit} }
{(sub || trend) &&
{sub || trend}
}
);
}
// ─────────────────────────────────────────────────────────────────────
// ALERT CARDS (home)
// ─────────────────────────────────────────────────────────────────────
function HomeAlert({ level, lang, title, area, value, until, description, scope = "Regional" }) {
const [isOpen, setIsOpen] = useState(false);
const accent = level === "warning"
? AERO.danger
: level === "watch"
? AERO.warm
: AERO.accent; // advisory
const levelLabel = level === "warning"
? (lang === "pt" ? "Alerta" : "Warning")
: level === "watch"
? (lang === "pt" ? "Atenção" : "Watch")
: (lang === "pt" ? "Aviso" : "Advisory");
const scopeColor = scope === 'Internacional' ? '#8e44ad' : scope === 'Nacional' ? '#2980b9' : '#27ae60';
return (
setIsOpen(!isOpen)}
style={{
display: 'block', position: 'relative',
padding: '22px 26px 22px 30px',
background: '#fff', border: `0.5px solid ${AERO.hair}`,
borderRadius: 4, textDecoration: 'none',
transition: 'box-shadow 0.18s',
cursor: 'pointer',
}}
onMouseEnter={e => e.currentTarget.style.boxShadow = `0 4px 20px rgba(0,0,0,0.07)`}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
>
{/* colour bar */}
{/* left column */}
{title}
{area && (
{area}
)}
{description && isOpen && (
{description}
)}
{/* right column */}
{value && (
{value}
)}
{until && (
{lang === "pt" ? "expira" : "expires"} {until}
)}
);
}
// ─────────────────────────────────────────────────────────────────────
// CITY GRID
// ─────────────────────────────────────────────────────────────────────
// CITY_DEFS — metadados estáticos; dados meteorológicos são buscados live.
const CITY_DEFS = [
{ id: "smr", name: "Santa Maria", region: "RS", lat: -29.69, lon: -53.84 },
{ id: "poa", name: "Porto Alegre", region: "RS", lat: -30.03, lon: -51.23 },
{ id: "sao", name: "São Paulo", region: "SP", lat: -23.55, lon: -46.63 },
{ id: "rio", name: "Rio de Janeiro", region: "RJ", lat: -22.91, lon: -43.17 },
{ id: "bsb", name: "Brasília", region: "DF", lat: -15.78, lon: -47.93 },
{ id: "rec", name: "Recife", region: "PE", lat: -8.05, lon: -34.88 },
{ id: "man", name: "Manaus", region: "AM", lat: -3.10, lon: -60.02 },
{ id: "for", name: "Fortaleza", region: "CE", lat: -3.72, lon: -38.54 },
{ id: "lis", name: "Lisboa", region: "PT", lat: 38.72, lon: -9.14 },
{ id: "lon", name: "London", region: "UK", lat: 51.51, lon: -0.13 },
{ id: "nyc", name: "New York", region: "NY", lat: 40.71, lon: -74.01 },
{ id: "tok", name: "Tokyo", region: "JP", lat: 35.68, lon: 139.69 },
];
function CityGrid({ lang }) {
// Estado inicial: esqueleto com dados null (mostra placeholder de carregamento).
const [cities, setCities] = useState(
CITY_DEFS.map(c => ({ ...c, temp: null, code: null, lo: null, hi: null, hour: null, wind: null }))
);
useEffect(() => {
// 1. Tenta via api.aerograma.pro/v1/cities (cache server-side, 1h)
window.WX.fetchCities().then(serverCities => {
if (serverCities && serverCities.length > 0) {
setCities(serverCities.map(c => ({
...c,
// Garante compatibilidade de campos
code: c.code ?? c.dcode ?? 3,
hour: c.hour ?? 12,
})));
return;
}
// 2. Fallback: batch direto
const lats = CITY_DEFS.map(c => c.lat).join(",");
const lons = CITY_DEFS.map(c => c.lon).join(",");
const url = [
"https://api.open-meteo.com/v1/forecast",
`?latitude=${lats}&longitude=${lons}`,
"¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m,is_day",
"&daily=temperature_2m_max,temperature_2m_min",
"&wind_speed_unit=ms&timezone=auto&forecast_days=1&models=best_match",
].join("");
fetch(url)
.then(r => r.ok ? r.json() : Promise.reject("HTTP " + r.status))
.then(results => {
const arr = Array.isArray(results) ? results : [results];
setCities(CITY_DEFS.map((def, i) => {
const d = arr[i];
if (!d) return { ...def, temp: null, code: null, lo: null, hi: null, hour: null, wind: null };
const cur = d.current || {};
const daily = d.daily || {};
let hour = new Date().getUTCHours();
if (d.utc_offset_seconds != null) {
hour = Math.floor(((Date.now() / 1000 + d.utc_offset_seconds) % 86400) / 3600);
}
return {
...def,
temp: cur.temperature_2m ?? null,
feels: cur.apparent_temperature ?? null,
code: cur.weather_code ?? null,
lo: (daily.temperature_2m_min || [])[0] ?? null,
hi: (daily.temperature_2m_max || [])[0] ?? null,
hour,
wind: cur.wind_speed_10m ?? null,
is_day: cur.is_day ?? 1,
};
}));
})
.catch(() => {
// Mantém esqueleto (já está como null, UI mostra "carregando…")
});
});
}, []);
return (
{cities.map((c) => )}
);
}
function CityCard({ city, lang }) {
const loading = city.temp === null;
const palette = window.WX.palette(city.code ?? 3, city.hour ?? 12);
const desc = describeCode(city.code ?? 3, lang);
return (
{city.region}
{city.name}
{loading ? "—" : Math.round(city.temp)}
°
{loading
? (lang === "pt" ? "carregando…" : "loading…")
: `${desc} · ${city.lo != null ? Math.round(city.lo) : "—"}° / ${city.hi != null ? Math.round(city.hi) : "—"}°`
}
);
}
function describeCode(code, lang) {
if (code === 0) return lang === "pt" ? "Limpo" : "Clear";
if (code <= 3) return lang === "pt" ? "Parc. nublado" : "Partly cloudy";
if (code >= 45 && code <= 48) return lang === "pt" ? "Neblina" : "Fog";
if (code >= 51 && code <= 57) return lang === "pt" ? "Garoa" : "Drizzle";
if (code >= 61 && code <= 67) return lang === "pt" ? "Chuva" : "Rain";
if (code >= 71 && code <= 77) return lang === "pt" ? "Neve" : "Snow";
if (code >= 80) return lang === "pt" ? "Pancadas" : "Showers";
if (code >= 95) return lang === "pt" ? "Tempestade" : "Thunderstorm";
return "";
}
// ─────────────────────────────────────────────────────────────────────
// MINI RADAR — wide map preview that links to the full radar page
// ─────────────────────────────────────────────────────────────────────
function MiniRadarBlock({ lang, palette }) {
return (
{lang === "pt" ? "Eco ativo" : "Active echo"}
{lang === "pt" ? "Núcleo convectivo ao oeste do RS" : "Convective core W of RS"}
{lang === "pt"
? "Refletividade 48 dBZ · deslocando NE a 32 km/h"
: "48 dBZ · moving NE at 32 km/h"}
{lang === "pt" ? "Abrir mapa inteiro →" : "Open full map →"}
);
}
// Stylized Brazil outline + radar blobs — wide aspect.
function BrazilMiniMap() {
// Simplified outline path. Approximate, decorative only.
return (
{/* lat/lon grid */}
{[60, 120, 180, 240, 300].map((y, i) => (
))}
{[150, 300, 450, 600, 750, 900, 1050].map((x, i) => (
))}
{/* stylized Brazil-ish landmass path (decorative) */}
{/* state divisions (decorative scribble) */}
{/* radar reflectivity blobs over RS area */}
{/* secondary cells elsewhere */}
{/* city markers */}
{[
[510, 270, "Santa Maria"],
[560, 290, "Porto Alegre"],
[800, 240, "São Paulo"],
[840, 220, "Rio de Janeiro"],
[720, 140, "Brasília"],
[600, 80, "Manaus"],
[880, 90, "Recife"],
].map(([x, y, n], i) => (
{n}
))}
{/* reflectivity legend overlay */}
REFLECTIVITY (dBZ)
{["#3a6f4b", "#5a9c5e", "#c5a23a", "#c97c3a", "#a8463a", "#7a2a6a"].map((c, i) => (
))}
{[5, 20, 30, 40, 50, 60].map((v, i) => (
{v}
))}
);
}
// ─────────────────────────────────────────────────────────────────────
// EDITORIAL CARDS
// ─────────────────────────────────────────────────────────────────────
function FeatureCard({ lang, kicker, title, excerpt, time, author, lead }) {
return (
{kicker} · {time}
{title}
{excerpt}
{author && {author}
}
);
}
// ─────────────────────────────────────────────────────────────────────
// BIG STATS
// ─────────────────────────────────────────────────────────────────────
function BigStat({ value, unit }) {
return (
);
}
Object.assign(window, { PageHome });