네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다.
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 에는 네 계절 전부 있는 것을 브라우저에서 확인.
219 lines
9.1 KiB
TypeScript
219 lines
9.1 KiB
TypeScript
/**
|
||
* 계절별 추천 하루 — 계절 탭 + 순위 카드.
|
||
*
|
||
* ★ 여행 스케줄(schedule)과 축이 다르다. 저쪽은 사장님이 시각을 적어 둔 시간표고,
|
||
* 여기는 **시각을 계산해 준다** — 사장님은 "몇 분 걸리나"만 적고, 출발 시각을 바꾸면 하루가 밀린다.
|
||
* 조립 규칙은 `@o2o/shared` 의 planDay 한 벌이다(빌더와 발행본이 같은 시각을 내야 한다).
|
||
* ★ 순위는 셋에서 끊는다. 넷째부터는 추천이 아니라 목록이 된다.
|
||
* ★ 손님 화면에는 **지금 계절만** 나간다(간절기에는 둘). 여기 탭은 사장님이 나머지 계절을
|
||
* 확인하려고 있는 것이라, 처음 열면 지금 계절에 맞춰 두고 그 사실을 아래에 적어 둔다 —
|
||
* 안 적으면 사장님은 손님도 네 계절을 다 본다고 오해한다.
|
||
*/
|
||
import {useMemo, useState} from 'react';
|
||
import {cn} from '@/lib/utils';
|
||
import {SectionBody, SectionFrame} from '../../primitives';
|
||
import type {SectionRenderProps} from '../../types';
|
||
import {
|
||
currentSeasons,
|
||
parseSectionData,
|
||
planDay,
|
||
plannerSeasons,
|
||
plannerTop,
|
||
type PlannerItem,
|
||
} from '@o2o/shared';
|
||
import {
|
||
ITEM_ACCENT,
|
||
ITEM_BODY,
|
||
ITEM_BORDER,
|
||
ITEM_CARD,
|
||
ITEM_HEADING,
|
||
ITEM_INK,
|
||
ITEM_INVERSE_INK,
|
||
ParseError,
|
||
PasteHint,
|
||
SourceLine,
|
||
} from '../items/common';
|
||
import '../items/items.css';
|
||
|
||
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 shadow-[4px_4px_0_rgba(0,0,0,.08)]"
|
||
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="text-lg" style={{fontFamily: ITEM_HEADING}}>
|
||
{item.name}
|
||
</h4>
|
||
{item.audience && <p className="text-[11px] opacity-60">{item.audience}</p>}
|
||
{item.why && (
|
||
<p className="text-[13px] leading-relaxed opacity-80" style={{fontFamily: ITEM_BODY}}>
|
||
{item.why}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{day.stops.length === 0 ? (
|
||
<p className="px-4 py-5 text-center text-[11px] opacity-60">
|
||
정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.
|
||
</p>
|
||
) : (
|
||
<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="pt-2 text-[12px] tabular-nums opacity-75" style={{fontFamily: ITEM_HEADING}}>
|
||
{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" style={{fontFamily: ITEM_BODY}}>
|
||
{planned.stop.name}
|
||
</p>
|
||
{planned.stop.note && (
|
||
<p className="mt-0.5 text-[12px] leading-relaxed opacity-75" style={{fontFamily: ITEM_BODY}}>
|
||
{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="space-y-1 border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
|
||
{/* ★ 상한에 걸려 뺀 칸을 숨기지 않는다. 숨기면 사장님은 자기가 적은 곳이 왜 없는지 모른다. */}
|
||
{day.dropped > 0 && (
|
||
<p className="text-[10px]" style={{color: ITEM_ACCENT}}>
|
||
밤 9시를 넘겨 {day.dropped}곳을 뺐습니다 — 머무는 시간을 줄이거나 출발을 당겨 보세요.
|
||
</p>
|
||
)}
|
||
<SourceLine source={item.source} verified={item.verified} />
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
export function PlannerPodium(props: SectionRenderProps) {
|
||
const {section, isSelected, onSelect} = props;
|
||
const parsed = parseSectionData<PlannerItem>(section.type, section.data);
|
||
const seasons = useMemo(() => plannerSeasons(parsed.items), [parsed.items]);
|
||
|
||
// 지금 계절(간절기면 둘 중 앞선 것)을 처음 탭으로. 손님이 보는 것과 같은 화면에서 시작한다.
|
||
const live = useMemo(() => currentSeasons().filter((s) => seasons.includes(s)), [seasons]);
|
||
const [picked, setPicked] = useState<number>();
|
||
const index = picked ?? Math.max(0, seasons.indexOf(live[0] ?? ''));
|
||
|
||
// 계절을 안 적었으면 탭 없이 전체에서 top3 를 뽑는다 — 빈 탭 줄을 그리지 않는다.
|
||
const season = seasons[index] ?? seasons[0];
|
||
const top = useMemo(() => plannerTop(parsed.items, season), [parsed.items, season]);
|
||
|
||
return (
|
||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="paper">
|
||
<SectionBody width="wide">
|
||
<div className="space-y-1.5">
|
||
<h2 className="text-2xl sm:text-3xl" style={{fontFamily: ITEM_HEADING}}>
|
||
{parsed.title || section.name}
|
||
</h2>
|
||
{(parsed.subtitle || section.description) && (
|
||
<p className="text-sm opacity-75" style={{fontFamily: ITEM_BODY}}>
|
||
{parsed.subtitle || section.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{parsed.error ? (
|
||
<ParseError message={parsed.error} />
|
||
) : parsed.items.length === 0 ? (
|
||
<PasteHint label="계절별 추천 하루" />
|
||
) : (
|
||
<div className="space-y-4">
|
||
{seasons.length > 1 && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{seasons.map((name, tabIndex) => (
|
||
<button
|
||
key={name}
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setPicked(tabIndex);
|
||
}}
|
||
aria-current={tabIndex === index}
|
||
className={cn(
|
||
'border px-3 py-1 text-xs transition-opacity',
|
||
tabIndex !== index && 'opacity-55',
|
||
)}
|
||
style={
|
||
tabIndex === index
|
||
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
|
||
: {borderColor: ITEM_BORDER}
|
||
}
|
||
>
|
||
{name}
|
||
{/* 손님 화면에 지금 나가는 계절. 사장님이 '어느 게 실제로 보이나'를 눈으로 안다. */}
|
||
{live.includes(name) && <span className="ml-1 opacity-70">·지금</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="w4-scroll flex snap-x snap-mandatory items-start gap-4 overflow-x-auto pb-3">
|
||
{top.map((item, rank) => (
|
||
<PlanCard key={`${item.name}-${rank}`} item={item} rank={rank} />
|
||
))}
|
||
</div>
|
||
|
||
<p className="text-[11px] opacity-60">
|
||
시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '}
|
||
{top.length}개 / 전체 {parsed.items.length}개
|
||
</p>
|
||
{live.length > 0 && (
|
||
<p className="text-[11px] opacity-60">
|
||
손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</SectionBody>
|
||
</SectionFrame>
|
||
);
|
||
}
|