/** * 계절별 추천 하루 — 계절 탭 + 순위 카드. * * ★ 여행 스케줄(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 (
{/* ★ 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.length === 0 ? (

정거장이 아직 없습니다. JSON 의 stops 배열을 채워 주세요.

) : (
    {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. ))}
)}
{/* ★ 상한에 걸려 뺀 칸을 숨기지 않는다. 숨기면 사장님은 자기가 적은 곳이 왜 없는지 모른다. */} {day.dropped > 0 && (

밤 9시를 넘겨 {day.dropped}곳을 뺐습니다 — 머무는 시간을 줄이거나 출발을 당겨 보세요.

)}
); } export function PlannerPodium(props: SectionRenderProps) { const {section, isSelected, onSelect} = props; const parsed = parseSectionData(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(); 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 (

{parsed.title || section.name}

{(parsed.subtitle || section.description) && (

{parsed.subtitle || section.description}

)}
{parsed.error ? ( ) : parsed.items.length === 0 ? ( ) : (
{seasons.length > 1 && (
{seasons.map((name, tabIndex) => ( ))}
)}
{top.map((item, rank) => ( ))}

시각은 출발 시각과 머무는 시간으로 계산한 것입니다 · {season ? `${season} 추천 ` : '추천 '} {top.length}개 / 전체 {parsed.items.length}개

{live.length > 0 && (

손님 화면에는 지금 계절({live.join(' · ')})만 나갑니다. 간절기에는 두 계절이 함께 보입니다.

)}
)}
); }