/** * 계절별 추천 하루 — 발행본. * * ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 12월에 온 손님에게 * 봄·여름 코스까지 늘어놓으면 고를 것이 열두 개가 되고, 그건 추천이 아니라 목록이다. * ★ 그런데 HTML 에는 **전 계절을 굽는다.** 두 가지 이유다 — * ① 발행본은 정적이라 한 번 구우면 몇 달을 산다. 굽는 시점의 계절을 박으면 12월에도 * 가을 코스가 걸린다. 그래서 계절 판정은 **브라우저에서** 한다(일력의 '오늘'과 같은 수법). * ② 이 사이트의 존재 이유가 인용이다. HTML 에서 지우면 검색·AI 가 나머지 계절을 못 읽는다. * → 안 맞는 계절은 `hidden` 으로 접어 둔다. 문서에는 있고 화면에는 없다. * ★ 시각은 화면이 계산한다(shared planDay) — 사장님은 머무는 시간만 적는다. * 조립 규칙이 빌더와 한 벌이라 사장님이 본 일정과 손님이 보는 일정이 같다. */ import {useEffect, useState} from 'react'; import {currentSeasons, planDay, plannerSeasons, plannerTop, type PlannerItem} from '@o2o/shared'; import {useSite} from '@/lib/site-context'; import {sectionItems, sectionName} from '@/lib/derive'; import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine} from './common'; const RANK_LABEL = ['1위', '2위', '3위']; /** 총 소요를 "7시간 10분"으로. 분만 쓰면 430분이 얼마인지 아무도 모른다. */ function spanText(minutes: number): string { const hour = Math.floor(minutes / 60); const rest = minutes % 60; return [hour > 0 ? `${hour}시간` : '', rest > 0 ? `${rest}분` : ''].filter(Boolean).join(' ') || '0분'; } function PlanCard({item, rank}: {item: PlannerItem; rank: number}) { const day = planDay(item); return (
{/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면 팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */} {RANK_LABEL[rank] ?? `${rank + 1}위`} {day.from}–{day.to} · {spanText(day.totalMinutes)}

{item.name}

{item.audience &&

{item.audience}

} {item.why &&

{item.why}

}
    {day.stops.map((planned, index) => (
  1. {/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */} {planned.time}
    {planned.move > 0 &&

    ↓ {planned.move}분 이동

    }

    {planned.stop.name}

    {planned.stop.note && (

    {planned.stop.note}

    )}

    {planned.time}–{planned.until} {planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`}

  2. ))}
); } export function PlannerSection() { const payload = useSite(); const parsed = sectionItems(payload, 'planner'); // ★ 서버 렌더에서는 undefined 다 — SSR 과 첫 렌더가 어긋나면 하이드레이션이 깨진다. // 브라우저에 올라온 뒤에야 계절이 정해지고, 그때 안 맞는 묶음이 접힌다. const [now, setNow] = useState(); useEffect(() => setNow(currentSeasons()), []); if (parsed.items.length === 0) return null; const seasons = plannerSeasons(parsed.items); // 계절을 안 적었으면 계절 묶음 없이 전체에서 top3 를 뽑는다 — 빈 소제목을 만들지 않는다. const groups = seasons.length > 0 ? seasons : [undefined]; // 지금 계절에 코스가 하나도 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 — // 빈 섹션보다 철 지난 코스가 낫다. const shown = now?.filter((season) => seasons.includes(season)) ?? []; const filtering = shown.length > 0; return (
{groups.map((season) => { const top = plannerTop(parsed.items, season); if (top.length === 0) return null; return ( ); })}
); }