o2o-site-AEO/solution/site/src/sections/items/PlannerSection.tsx
Mina Choi 65705c5bed [feat] solution/shared,site,frontend: 계절별 추천 하루는 지금 계절만 — 간절기엔 두 계절
네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다.

- shared/currentSeasons: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울.
  계절 첫 달의 전반(1~15일)은 간절기로 보고 앞 계절과 함께 둘을 돌려준다 —
  9월 초에 여름만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다
- site/PlannerSection: HTML 에는 전 계절을 굽고 화면에서만 접는다(hidden).
  ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸려서,
     계절 판정을 브라우저에서 한다(일력의 '오늘'과 같은 수법)
  ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다
  지금 계절에 코스가 없으면 접지 않고 전부 보여준다 — 빈 섹션보다 철 지난 코스가 낫다
- frontend/PlannerPodium: 탭은 그대로 두되 지금 계절로 열리고, '·지금' 표시와
  "손님 화면에는 지금 계절만 나갑니다" 한 줄. 안 적으면 사장님은 손님도 넷을 다 본다고 오해한다

tsc·eslint 통과(frontend·site). 경계 12일자 확인(3/5 겨울·봄 · 9/2 여름·가을 · 9/16 가을).
실물 payload(스테이,머뭄 /s/stay, 9코스 4계절)로 구워 오늘 여름·가을만 보이고 봄·겨울은
hidden, HTML 에는 네 계절 전부 있는 것을 브라우저에서 확인.
2026-09-02 21:58:50 +09:00

146 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 계절별 추천 하루 — 발행본.
*
* ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 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 (
<article
className="w4-paper w-[320px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
{/* ★ 1위만 채운다. 셋 다 채우면 순위가 안 읽히고, 채움색을 강조색으로 두면
팔레트에 따라 글자가 안 보인다(연한 accent 위의 밝은 글자) — 글자색으로 채운다. */}
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={
rank === 0
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{RANK_LABEL[rank] ?? `${rank + 1}위`}
</span>
<span className="text-[11px] opacity-60">
{day.from}–{day.to} · {spanText(day.totalMinutes)}
</span>
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="serif text-lg font-bold">{item.name}</h4>
{item.audience && <p className="text-[11px] opacity-60">{item.audience}</p>}
{item.why && <p className="text-[13px] leading-relaxed opacity-80">{item.why}</p>}
</div>
<ol className="mt-3 px-4 pb-3">
{day.stops.map((planned, index) => (
<li key={`${planned.stop.name}-${index}`} className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5">
{/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다 */}
<span className="serif pt-2 text-[12px] tabular-nums opacity-75">{planned.time}</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && <p className="pt-1 text-[10px] opacity-50">↓ {planned.move}분 이동</p>}
<p className="pt-1 text-sm font-bold">{planned.stop.name}</p>
{planned.stop.note && (
<p className="mt-0.5 text-[12px] leading-relaxed opacity-75">{planned.stop.note}</p>
)}
<p className="mt-0.5 text-[10px] opacity-50">
{planned.time}–{planned.until}
{planned.stop.searchQuery && ` · 지도 검색 ${planned.stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
<div className="border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={item.source} verified={item.verified} />
</div>
</article>
);
}
export function PlannerSection() {
const payload = useSite();
const parsed = sectionItems<PlannerItem>(payload, 'planner');
// ★ 서버 렌더에서는 undefined 다 — SSR 과 첫 렌더가 어긋나면 하이드레이션이 깨진다.
// 브라우저에 올라온 뒤에야 계절이 정해지고, 그때 안 맞는 묶음이 접힌다.
const [now, setNow] = useState<string[]>();
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 (
<ItemSection
id="planner"
name={parsed.title || sectionName(payload, 'planner', '계절별 추천 하루')}
subtitle={parsed.subtitle}
count={
filtering
? `지금은 ${shown.join(' · ')} 코스입니다 · 시각은 출발 시각과 머무는 시간으로 계산한 것입니다`
: '시각은 출발 시각과 머무는 시간으로 계산한 것입니다'
}
>
<div className="space-y-10">
{groups.map((season) => {
const top = plannerTop(parsed.items, season);
if (top.length === 0) return null;
return (
<div
key={season ?? 'all'}
className="space-y-3"
// 문서에는 남기고 화면에서만 접는다 — 검색·AI 는 전 계절을 읽는다.
hidden={filtering && season !== undefined && !shown.includes(season)}
>
{season && <h3 className="serif text-lg font-bold">{season}</h3>}
<Rail label={`${season ?? '추천'} 코스`}>
{top.map((item, index) => (
<PlanCard key={`${item.name}-${index}`} item={item} rank={index} />
))}
</Rail>
</div>
);
})}
</div>
</ItemSection>
);
}