네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
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 에는 네 계절 전부 있는 것을 브라우저에서 확인.
146 lines
6.7 KiB
TypeScript
146 lines
6.7 KiB
TypeScript
/**
|
||
* 계절별 추천 하루 — 발행본.
|
||
*
|
||
* ★ 손님에게는 **지금 계절만** 보인다(간절기에는 두 계절). 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>
|
||
);
|
||
}
|