모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려 scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다. - lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정 (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용) - sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합 - sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx, items/*: 표시 폭·간격·라벨 정리 - lib/ui/Carousel.tsx: loop 이음매 간격 재점검 - scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지 않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로 tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
126 lines
5.6 KiB
TypeScript
126 lines
5.6 KiB
TypeScript
/**
|
|
* 오늘의 날씨.
|
|
*
|
|
* ★ 온도 두 조각으로는 아무 값이 없다 (2026-09-04, 사장님: "컨텐츠가 있어야 하지 않나")
|
|
* `27.9°C 구름많음` 은 손님 폰 잠금화면에 이미 있다. 이 섹션이 답해야 하는 건
|
|
* 날씨가 아니라 **"그래서 오늘 이 집에서 뭘 하지"** 다. 그래서 셋을 얹는다 —
|
|
* ① 날씨별 한 줄(사장님이 미리 적어 둔 것) ② 관측 시각 ③ 그 날씨의 그림.
|
|
* ★ 문장을 하나만 두지 않는다. 발행본은 정적인데 날씨는 브라우저에서 갱신되므로,
|
|
* 굽는 순간의 문장을 박으면 비 오는 날 "마당에 앉기 좋습니다"가 뜬다.
|
|
* ★ 관측 시각을 반드시 낸다. 시각 없는 숫자는 못 믿는다 — 캐시가 어긋나면 어제 값이다.
|
|
*/
|
|
import {Cloud, CloudRain, CloudSnow, Sun} from 'lucide-react';
|
|
import type {WeatherSky} from '@o2o/shared';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {useLiveWeather} from '@site/lib/use-live-weather';
|
|
import {useWeatherNotes} from '@site/lib/use-weather-note';
|
|
import {weatherBand, weatherMood} from '@site/lib/derive';
|
|
import {Card, Section} from '@site/lib/ui';
|
|
|
|
const MOOD_ICON = {맑음: Sun, 흐림: Cloud, 비: CloudRain, 눈: CloudSnow} as const;
|
|
|
|
/** "2026-09-03T12:30" → "9월 3일 12:30". 날짜가 오늘이면 시각만 남긴다. */
|
|
function observedText(iso: string | undefined): string | undefined {
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(iso ?? '');
|
|
if (!m) return undefined;
|
|
const [, , month, day, hour, minute] = m;
|
|
return `${Number(month)}월 ${Number(day)}일 ${hour}:${minute} 관측`;
|
|
}
|
|
|
|
export function WeatherSection() {
|
|
const payload = useSite();
|
|
const weather = useLiveWeather({
|
|
initial: payload.local.weather,
|
|
regionCode: payload.place.regionCode,
|
|
latitude: payload.place.latitude,
|
|
longitude: payload.place.longitude,
|
|
});
|
|
|
|
const mood = weatherMood(weather?.condition ?? '');
|
|
const band = weatherBand(weather?.temperature ?? 0);
|
|
const notes = payload.local.weather;
|
|
// 하늘 문구는 아홉 갈래로 고른다(WeatherSky). 옛 payload 처럼 그 칸이 없으면 그림 넷(mood)으로 떨어진다.
|
|
const condition = (weather?.condition ?? '') as WeatherSky;
|
|
const skyKey: WeatherSky = notes?.noteSets?.[condition] ? condition : mood;
|
|
/*
|
|
* ★ 기온 줄 (2026-09-09, 사장님: "기온에 따라서 문구 다르게, 펜션지역의 관광에 맞춰서")
|
|
* 하늘 줄은 집 안 이야기(마당·CAFÉ)라 "그래서 오늘 어디를 가지"에는 답하지 않았다.
|
|
* 34도의 맑음과 3도의 맑음이 같은 문장을 받는 것도 이 때문이었다.
|
|
* ★ 없으면 줄 자체를 그리지 않는다 — 기온대는 항상 계산되지만 문장은 사장님이 적은
|
|
* 것뿐이라, 빈 칸을 우리가 채우면 지어낸 관광 안내가 된다.
|
|
* ★ 둘을 한 타이머로 같이 뽑는다 — 따로 뽑으면 두 줄이 각자 다른 순간에 바뀐다.
|
|
*/
|
|
const [line, bandLine] = useWeatherNotes(
|
|
notes?.noteSets?.[skyKey], notes?.notes?.[mood] ?? weather?.note,
|
|
notes?.tempNoteSets?.[band], notes?.tempNotes?.[band],
|
|
);
|
|
if (!weather) return null;
|
|
const observed = observedText(weather.observedAt);
|
|
const SkyIcon = MOOD_ICON[mood];
|
|
|
|
return (
|
|
<Section id="weather" tone="alt" title="오늘의 날씨">
|
|
<Card className="relative min-h-64 overflow-hidden">
|
|
{/* 아이콘은 글 뒤에 깔린다. aria-hidden — 낭독기가 읽을 것이 없다. */}
|
|
<SkyIcon
|
|
aria-hidden
|
|
strokeWidth={1}
|
|
className="pointer-events-none absolute -bottom-10 -right-10 size-64 opacity-[0.08]"
|
|
/>
|
|
|
|
<div className="relative flex flex-col gap-5 p-6 sm:p-8">
|
|
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
|
<p className="flex items-baseline gap-2.5">
|
|
<span
|
|
className="serif tabular-nums leading-none"
|
|
style={{fontSize: 'var(--fs-display)'}}
|
|
>
|
|
{Math.round(weather.temperature)}
|
|
</span>
|
|
<span className="text-[length:var(--fs-lead)] opacity-100">°C</span>
|
|
<span className="text-[length:var(--fs-lead)] font-semibold">
|
|
{weather.condition}
|
|
</span>
|
|
</p>
|
|
{observed && (
|
|
<span className="text-[length:var(--fs-sm)] tabular-nums">
|
|
{observed}
|
|
{weather.stale && ' · 최근 관측값'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 사장님이 자기 집을 두고 쓴 한 줄. 날씨 예보가 아니다. */}
|
|
{line && (
|
|
<p
|
|
key={line}
|
|
className="w4-note-fade measure serif text-[length:var(--fs-lead)] leading-loose opacity-100"
|
|
>
|
|
{line}
|
|
</p>
|
|
)}
|
|
|
|
{/*
|
|
기온 줄은 하늘 줄 아래에 둔다 — 손님이 먼저 보는 건 "비 오나"이고
|
|
"그럼 어디 가지"는 그다음이다. 활자를 한 급 낮춰 둘의 순서를 눈에도 보이게 한다.
|
|
*/}
|
|
{bandLine && (
|
|
<p
|
|
key={bandLine}
|
|
className="w4-note-fade measure flex items-start gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-100"
|
|
>
|
|
<span
|
|
className="border-line mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-sm)] font-bold"
|
|
aria-hidden
|
|
>
|
|
{band}
|
|
</span>
|
|
<span>{bandLine}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</Section>
|
|
);
|
|
}
|