import {useEffect, useState} from 'react'; import type {WeatherSnapshot} from '@o2o/shared'; export interface LiveWeather extends WeatherSnapshot { stale?: boolean; } /** * WMO weather code → 한 줄. 코드 하나마다 고유 문구다(뭉치지 않는다) — ★ * solution/backend/services/site_payload.py 의 _WEATHER_CONDITION_BY_CODE 와 같은 표여야 한다. */ const WEATHER_CONDITION_BY_CODE: Record = { 0: '맑음', 1: '대체로 맑음', 2: '구름 조금', 3: '흐림', 45: '안개', 48: '착빙성 안개', 51: '가벼운 이슬비', 53: '보통 이슬비', 55: '강한 이슬비', 56: '가벼운 착빙성 이슬비', 57: '강한 착빙성 이슬비', 61: '약한 비', 63: '보통 비', 65: '강한 비', 66: '약한 착빙성 비', 67: '강한 착빙성 비', 71: '약한 눈', 73: '보통 눈', 75: '강한 눈', 77: '싸라기눈', 80: '약한 소나기', 81: '보통 소나기', 82: '강한 소나기', 85: '약한 소나기눈', 86: '강한 소나기눈', 95: '뇌우', 96: '약한 우박 뇌우', 99: '강한 우박 뇌우', }; export function condition(code: number): string { return WEATHER_CONDITION_BY_CODE[code] ?? '흐림'; } /** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */ export function useLiveWeather({ initial, regionCode, latitude, longitude, }: { initial?: WeatherSnapshot; regionCode?: string; latitude?: number; longitude?: number; }): LiveWeather | undefined { const [weather, setWeather] = useState(initial); useEffect(() => { if (!regionCode || latitude == null || longitude == null) return; const controller = new AbortController(); async function refresh() { try { /* * ★ **같은 오리진의 상대 주소로 부른다.** * 전에는 `import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9800'` 이었다. * `VITE_*` 는 번들에 구워지므로 그 값이 없는 자리에서 구우면 **발행본이 방문자 * 브라우저에서 `localhost:9800` 을 부른다** — 실측(2026-09-09, `/s/stay`): * CORS 로 전부 막혀 실시간 날씨가 한 번도 반영되지 않았고, 화면에는 굽던 날의 * 기온(6일 전 27.9°C)이 그대로 떠 있었다. 관측 시각을 같이 내도록 해 둔 덕에 * 거짓말은 아니었지만, 손님에게는 오늘 날씨로 읽힌다. * 애초에 이 렌더러의 규칙이 **런타임 환경변수를 쓰지 않는 것**이다(site/.env.example) — * env 를 채워 다시 굽는 건 같은 함정을 다음 사람에게 물려주는 것이다. * ★ 상대 주소면 nginx 가 앱·발행본·API 를 한 오리진으로 주므로(CLAUDE.md) 그대로 닿고, * CORS 도 없다. 오리진이 다른 곳(블롭 단독)에 올라가면 404 로 실패하는데, * 그때는 아래 catch 가 구운 스냅샷을 유지한다 — 날씨가 사이트를 깨지 않는다. */ const query = new URLSearchParams({ region_code: regionCode!, latitude: String(latitude), longitude: String(longitude), }); const response = await fetch(`/v1/local/weather?${query}`, { signal: controller.signal, }); if (!response.ok) return; const body = await response.json(); if (!body?.result?.success || !body.weather) return; setWeather((previous) => ({ temperature: Number(body.weather.temperature), condition: condition(Number(body.weather.weather_code)), note: previous?.note, observedAt: body.weather.observed_at, stale: Boolean(body.stale), })); } catch { // 정적 payload의 날씨를 유지한다. 날씨 장애가 사이트 렌더를 깨면 안 된다. } } void refresh(); const timer = window.setInterval(() => void refresh(), 10 * 60 * 1000); return () => { controller.abort(); window.clearInterval(timer); }; }, [latitude, longitude, regionCode]); return weather; }