o2o-site-AEO/solution/site/src/lib/use-live-weather.ts
Mina Choi b78486b845 Merge commit '46171b4f24f2ddc3b9212b9ad6769925bcaceb03' into feature/site-features-and-mockup
# Conflicts:
#	.env.example
#	solution/backend/common/enums.py
#	solution/backend/requirements.txt
#	solution/backend/services/site_payload.py
#	solution/backend/services/snapshot.py
#	solution/backend/services/weather_notes.json
#	solution/shared/src/types/site-payload.ts
#	solution/site/src/layouts/editorial/Shell.tsx
#	solution/site/src/lib/use-live-weather.ts
#	solution/site/src/pages/HomePage.tsx
#	solution/site/src/sections/SiteFooter.tsx
#	solution/site/src/sections/WeatherSection.tsx
#	solution/site/src/sections/index.ts
2026-09-18 09:44:10 +09:00

89 lines
3.8 KiB
TypeScript

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() 과 같은 구간이어야 한다. */
export function condition(code: number): string {
if (code === 0) return '맑음';
if (code >= 1 && code <= 2) return '구름많음';
if (code === 3) return '흐림';
if (code === 45 || code === 48) return '안개';
if (code >= 51 && code <= 57) return '이슬비';
if (code >= 61 && code <= 67) return '비';
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return '눈';
if (code >= 80 && code <= 82) return '소나기';
if (code >= 95 && code <= 99) return '뇌우';
return '흐림';
}
/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */
export function useLiveWeather({
initial,
regionCode,
latitude,
longitude,
}: {
initial?: WeatherSnapshot;
regionCode?: string;
latitude?: number;
longitude?: number;
}): LiveWeather | undefined {
const [weather, setWeather] = useState<LiveWeather | undefined>(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;
}