/** * 추천 일정 — 발행본. **계절별 추천 하루와 같은 승차권 카드**로 그린다. * * ★ 왜 UI 를 합쳤나 (2026-09-03, 사장님 지시) * 같은 페이지에서 '계절별 추천 하루'는 순위 카드 + 시간표인데 '추천 일정'만 다른 모양이라, * 손님 눈에는 성격이 다른 두 물건으로 보였다. 둘 다 **하루를 시각으로 짜 주는 것**이 핵심이고 * 축만 다르다(저쪽은 계절, 이쪽은 며칠 묵느냐). 축이 다르다고 생김새까지 다를 이유가 없다. * * ★ 여러 날짜리는 **날마다 카드 한 장**이다. 한 카드에 이틀을 밀어 넣으면 시간표가 두 번 끊겨 * '하루'라는 단위가 사라진다. 카드 머리의 배지가 며칠째인지 말한다. * ★ 시각은 화면이 계산한다(shared `planDay`) — 사장님은 출발 시각과 머무는 시간만 적는다. * 빌더와 같은 함수라 사장님이 본 일정과 손님이 보는 일정이 같다. * * ★ 기간(`duration`)으로 탭을 세운다 (2026-09-07, 사장님 지시) * 안이 하나뿐일 때는 제목을 세로로 쌓아도 됐는데, 1박 2일·2박 3일을 각각 세 안씩 두니 * 여섯 덩이가 세로로 이어져 손님이 자기 일정에 맞는 것을 찾으려면 남의 일정을 다 지나야 했다. * 손님이 이 섹션에 올 때 이미 정해 온 것은 **며칠 묵느냐**다 — 그게 먼저 갈리는 축이다. * ★ 안 고른 탭도 HTML 에 남긴다(`hidden`). 지우면 검색·AI 가 나머지 일정을 못 읽는다 * — FestivalSection 과 같은 규칙이다. * ★ 기본값은 **첫 번째 기간**이다. `useState` 초깃값이라 SSR 과 어긋나지 않는다. */ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {planDay, type ItineraryItem, type PlannerStop} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; import {sectionItems, sectionName} from '@site/lib/derive'; import {ITEM_BORDER, ITEM_CARD, ITEM_INK, ITEM_INVERSE_INK, ItemSection, Rail, SourceLine, StopNo} from './common'; import {RouteMap} from './RouteMap'; /** 총 소요를 "3시간 58분"으로. 분만 쓰면 238분이 얼마인지 아무도 모른다. */ 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분'; } /** 일정 하나 → 카드로 그릴 하루 목록. `days` 가 없으면 하루짜리다(반나절 코스가 그렇다). */ function daysOf(item: ItineraryItem): {label?: string; startTime?: string; stops: PlannerStop[]}[] { if (item.days?.length) { return item.days.map((day) => ({ label: day.label, startTime: day.startTime, stops: day.stops ?? [], })); } return [{startTime: item.startTime, stops: item.stops ?? []}]; } function DayCard({ item, badge, startTime, stops, first, placeName, continuesFrom, continuesTo, }: { item: ItineraryItem; badge: string; startTime?: string; stops: PlannerStop[]; first: boolean; /** 업소 상호명 — 출발지 표시용. 일정 데이터에는 없다(프롬프트가 정거장으로 못 넣게 막는다). */ placeName: string; /** 같은 코스의 전날에서 이어지는 카드다 — 왼쪽 테두리를 지우고 앞 카드에 붙인다. */ continuesFrom?: boolean; /** 같은 코스의 다음날로 이어진다 — 오른쪽 테두리를 점선으로 바꾼다. */ continuesTo?: boolean; }) { const day = planDay({name: item.name, startTime, stops}); /* ★ 기본은 시간표만. 지도와 정거장 설명은 접는다 — 카드 하나가 2.5화면을 먹고 있었고, 지도는 카드마다 OSM 타일을 부른다(첫 화면에 53장). 설명은 `hidden` 이라 DOM 에 남는다. ★ 데스크톱은 펼친 채로 시작한다(대표: "데스크톱에서는 지도랑 다 보이게") — 렌더 중에 matchMedia 를 읽으면 하이드레이션이 어긋나므로 붙은 뒤 effect 에서 한 번만 켠다. */ const [detail, setDetail] = useState(false); useEffect(() => { if (window.matchMedia('(min-width: 768px)').matches) setDetail(true); }, []); return ( /* * ★ 코스 경계에 여백을 더 주지 않는다 (2026-09-09, 사장님: "간격 다 똑같이") * 한때 코스가 바뀌는 카드에 `ml-6 border-l-2` 를 걸어 48px 로 벌렸다. 레일에 코스 셋이 * 섞여 있어 경계가 안 보인다고 봤기 때문인데, **코스 줄**(DurationRail)이 레일 위에 * 생기면서 그 일을 글자가 대신한다 — 지금 보고 있는 코스에 밑줄이 간다. * 경계 여백이 남으면 같은 레일 안에서 카드 간격이 두 종류가 되고, 그게 더 눈에 걸린다. */
{/* ★ 첫 장만 채운다. 다 채우면 며칠째인지가 안 읽힌다(순위 카드와 같은 규칙). */} {badge} {day.from}–{day.to} · {spanText(day.totalMinutes)}
{/* ★ h3 다 — 레일이 탭마다 하나로 합쳐지면서(2026-09-09) 카드 밖 코스 제목이 없어졌다. 카드 안 제목이 그 코스의 유일한 표제가 되므로 단계를 올린다. 여러 날짜리는 같은 제목이 날마다 반복되는데, 카드 하나만 인용돼도 무슨 코스인지 읽혀야 해서 그대로 둔다. */}

{item.name}

{/* ★ why·audience 는 첫 장만 — 다 채우면 같은 문구가 이틀·사흘 반복된다. */} {first && item.audience &&

{item.audience}

} {first && item.why &&

{item.why}

} {/* ★ 출발지 — 첫 장만(위 audience·why 와 같은 규칙). day.from 은 planDay 가 계산한 출발 시각(item.startTime)이라 시간표의 첫 줄과 정확히 이어진다. */} {first &&

{placeName}에서 출발 · {day.from}

}
{detail && planned.stop)} />}
    {day.stops.map((planned, index) => (
  1. {/* 시각 열 — 왼쪽에 붙어 정렬돼야 '시간표'로 읽힌다. */} {planned.time} {/* ★ 정거장 사이를 벌린다 (2026-09-09, 간격 감사) pb-2 였을 때 정거장 사이가 13px 인데 정거장 **안**의 줄간격이 10px 이라, 둘이 거의 같아 시간표가 한 덩이 글로 뭉갰다 — 어디까지가 한 정거장인지 눈으로 안 갈렸다. 안(10px)보다 뚜렷하게 넓어야 목록으로 읽힌다. ★ 사이를 선으로 끊지 않는다. 왼쪽 `border-l` 이 이미 하루를 잇는 세로 레일이라 가로 선을 더하면 레일이 토막나고, 시간표가 아니라 표가 된다. */}
    {planned.move > 0 &&

    ↓ {planned.move}분 이동

    }
    {/* 사진은 있을 때만. 없으면 빈 회색 상자 대신 활자만 선다(이 팩의 규칙). */} {planned.stop.imageUrl && detail && ( {`${planned.stop.name} )} {planned.stop.name} {planned.stop.note && ( )}
  2. ))}
); } /** 기간이 비어 있는 일정은 한 자리에 모은다 — 빈 이름의 탭을 세우지 않는다. */ const NO_DURATION = '그 밖의 일정'; export function ItinerarySection() { const payload = useSite(); const parsed = sectionItems(payload, 'itinerary'); /* * ★ 사장님이 적은 일정이 없으면 **서버가 조립한 것**을 쓴다(`local.itineraries`). * 이 두 자리가 갈라져 있어서, 주변 정보를 다 수집하고 일정까지 계산해 놓고도 * 화면에는 아무것도 안 나왔다(실측 2026-09-09, 조이모텔 — 관광지 20·맛집 20·축제 20). * 사장님이 적은 것이 언제나 이긴다 — 우리가 만든 건 비었을 때의 기본값이다. */ const source = parsed.items.length > 0 ? parsed.items : (payload.local?.itineraries ?? []); // 들를 곳이 하나도 없는 일정은 탭 계산 전에 뺀다 — 눌러도 빈 화면인 탭이 생긴다. const items = source.filter((item) => daysOf(item).some((day) => day.stops.length > 0)); // 적힌 순서가 곧 탭 순서다. 우리가 "1박 2일이 먼저"라고 정하지 않는다. const tabs = [...new Set(items.map((item) => item.duration?.trim() || NO_DURATION))]; const [picked, setPicked] = useState(null); const active = picked ?? tabs[0]; if (items.length === 0) return null; return ( {tabs.length > 1 && (
{tabs.map((tab) => { const on = active === tab; return ( ); })}
)} {/* ★ 탭 하나 = 레일 하나 (2026-09-09, 사장님: "추천일정 1열로 만들어서 auto play") 예전에는 안(코스)마다 제목 + 레일을 두고 세로로 쌓았다. 한 탭에 안이 셋이면 카드 높이가 그대로 세 번 쌓여 이 섹션 하나가 화면 세 개를 먹었다 — 손님은 두 번째 안을 보려고 첫 안의 시간표를 다 지나야 했다. ★ 접힌 탭은 폭이 0 이라 embla 가 스냅을 잘못 잡는다 → key 로 활성 탭이 바뀔 때 다시 태운다. */} {tabs.map((tab) => ( ))}
); } /** * 기간 탭 하나의 레일 — **코스 줄 + 날짜 카드 한 줄**. * * ★ 왜 코스 줄이 필요한가 (2026-09-09, 사장님: "일렬로 되었으니까 타이틀이 안보이잖아") * 코스마다 레일을 따로 두던 때는 제목이 레일 위에 있어서, 세로로 훑기만 하면 * "이 기간에 무슨 코스가 있나"를 알 수 있었다. 한 줄로 합치자 그 목록이 사라졌다 — * 카드 안에 제목이 있어도 **지금 보이는 한 장의 제목**이라 전체 목록이 아니고, * 자동 넘김까지 돌아 처음 본 카드가 둘째 날인 경우도 생긴다(실측). * ★ 그래서 코스 이름을 레일 위에 한 줄로 늘어놓고, 지금 보고 있는 코스를 표시한다. * 누르면 그 코스의 첫날로 레일이 넘어간다 — 목록이자 이동 수단이다. * ★ 기간 탭(강조)과 층을 가른다: 저쪽은 알약 버튼이고 이쪽은 글자다. * 같은 모양으로 두 줄을 세우면 어느 쪽이 상위인지 안 읽힌다. * ★ 코스가 하나면 줄을 그리지 않는다 — 선택지 없는 목록은 장식이다. * ★ `audience`("차 없이 오는 손님")는 카드에만 둔다. 줄에도 적으면 같은 문구가 위아래로 * 두 번 나오고, 코스가 하나일 때는 줄이 아예 없어서 그 문구만 사라진다. */ function DurationRail({ tab, items, placeName, }: { tab: string; items: ItineraryItem[]; placeName: string; }) { /** 코스별 시작 위치와 길이. 레일 인덱스 ↔ 코스를 잇는 유일한 표다. */ const courses = useMemo(() => { let cursor = 0; return items.map((item) => { const start = cursor; cursor += daysOf(item).length; return {item, start}; }); }, [items]); const [slide, setSlide] = useState(0); const api = useRef<{scrollTo: (index: number) => void} | null>(null); const onReady = useCallback((next: {scrollTo: (index: number) => void}) => { api.current = next; }, []); // 지금 보이는 장이 속한 코스 — 시작 위치가 그 장보다 앞인 것 중 마지막. const currentStart = courses.reduce( (found, course) => (course.start <= slide ? course.start : found), 0, ); return ( <> {courses.length > 1 && ( )} {courses.flatMap(({item, start}) => { const days = daysOf(item); // ★ 같은 코스의 날짜 카드는 붙여서 한 판처럼 잇는다 — 앞 카드 오른쪽 테두리를 // 점선으로, 뒷 카드 왼쪽 테두리는 지운다(대표: "같은 일정이면 띄어놓지 말라고"). // 코스가 갈리는 경계에는 이 처리가 없어 레일 위에서 그대로 구분된다. return days.map((day, dayIndex) => ( 0} continuesTo={dayIndex < days.length - 1} /> )); })} ); }