o2o-site-AEO/solution/site/src/sections/items/ItinerarySection.tsx
Mina Choi 4260e20a70 [fix] site: 모달 닫기 스크롤 버그 수정 — 이용안내 체크인·체크아웃 병합, UI 다듬기
모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려
scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다.

- lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정
  (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용)
- sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합
- sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx,
  items/*: 표시 폭·간격·라벨 정리
- lib/ui/Carousel.tsx: loop 이음매 간격 재점검
- scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지
  않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로

tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:23:20 +09:00

373 lines
18 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.

/**
* 추천 일정 — 발행본. **계절별 추천 하루와 같은 승차권 카드**로 그린다.
*
* ★ 왜 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)이 레일 위에
* 생기면서 그 일을 글자가 대신한다 — 지금 보고 있는 코스에 밑줄이 간다.
* 경계 여백이 남으면 같은 레일 안에서 카드 간격이 두 종류가 되고, 그게 더 눈에 걸린다.
*/
<article
className={`w4-paper w-[320px] shrink-0 snap-center border-y ${
continuesFrom ? '-ml-4 border-l-0' : 'border-l'
} ${continuesTo ? '' : 'border-r'}`}
style={{
backgroundColor: ITEM_CARD,
borderColor: ITEM_BORDER,
...(continuesTo ? {borderRight: `2px dashed ${ITEM_BORDER}`} : null),
}}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
style={{borderColor: ITEM_BORDER}}
>
{/* ★ 첫 장만 채운다. 다 채우면 며칠째인지가 안 읽힌다(순위 카드와 같은 규칙). */}
<span
className="border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
style={
first
? {backgroundColor: ITEM_INK, color: ITEM_INVERSE_INK, borderColor: ITEM_INK}
: {borderColor: ITEM_BORDER}
}
>
{badge}
</span>
<span className="text-[length:var(--fs-xs)] opacity-100">
{day.from}{day.to} · {spanText(day.totalMinutes)}
</span>
</div>
<div className="space-y-1.5 px-4 pt-3.5">
{/*
★ h3 다 — 레일이 탭마다 하나로 합쳐지면서(2026-09-09) 카드 밖 코스 제목이 없어졌다.
카드 안 제목이 그 코스의 유일한 표제가 되므로 단계를 올린다. 여러 날짜리는 같은
제목이 날마다 반복되는데, 카드 하나만 인용돼도 무슨 코스인지 읽혀야 해서 그대로 둔다.
*/}
<h3 className="serif text-lg font-bold">{item.name}</h3>
{/* ★ why·audience 는 첫 장만 — 다 채우면 같은 문구가 이틀·사흘 반복된다. */}
{first && item.audience && <p className="text-[length:var(--fs-sm)] opacity-100">{item.audience}</p>}
{first && item.why && <p className="text-[length:var(--fs-sm)] leading-relaxed opacity-100">{item.why}</p>}
{/* ★ 출발지 — 첫 장만(위 audience·why 와 같은 규칙). day.from 은 planDay 가 계산한
출발 시각(item.startTime)이라 시간표의 첫 줄과 정확히 이어진다. */}
{first && <p className="text-[length:var(--fs-xs)] opacity-100">{placeName} · {day.from}</p>}
</div>
{detail && <RouteMap stops={day.stops.map((planned) => planned.stop)} />}
<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-[length:var(--fs-sm)] tabular-nums opacity-100">{planned.time}</span>
{/*
★ 정거장 사이를 벌린다 (2026-09-09, 간격 감사)
pb-2 였을 때 정거장 사이가 13px 인데 정거장 **안**의 줄간격이 10px 이라,
둘이 거의 같아 시간표가 한 덩이 글로 뭉갰다 — 어디까지가 한 정거장인지
눈으로 안 갈렸다. 안(10px)보다 뚜렷하게 넓어야 목록으로 읽힌다.
★ 사이를 선으로 끊지 않는다. 왼쪽 `border-l` 이 이미 하루를 잇는 세로 레일이라
가로 선을 더하면 레일이 토막나고, 시간표가 아니라 표가 된다.
*/}
<div className={`border-l pl-3 ${detail ? 'pb-5' : 'pb-2'}`} style={{borderColor: ITEM_BORDER}}>
{planned.move > 0 && <p className="pt-1 text-[length:var(--fs-xs)] opacity-100"> {planned.move} </p>}
<div className="flex items-start gap-2.5 pt-1">
{/* 사진은 있을 때만. 없으면 빈 회색 상자 대신 활자만 선다(이 팩의 규칙). */}
{planned.stop.imageUrl && detail && (
<img
src={planned.stop.imageUrl}
alt={`${planned.stop.name} 사진`}
loading="lazy"
decoding="async"
className="size-12 shrink-0 object-cover"
style={{border: `1px solid ${ITEM_BORDER}`}}
/>
)}
<span className="min-w-0">
<span className="block text-[length:var(--fs-sm)] font-bold">
<StopNo n={index + 1} />
{planned.stop.name}
</span>
{planned.stop.note && (
<span hidden={!detail} className="mt-0.5 block text-[length:var(--fs-sm)] leading-relaxed opacity-100">
{planned.stop.note}
</span>
)}
</span>
</div>
<p hidden={!detail} className="mt-0.5 text-[length:var(--fs-xs)] opacity-100">
{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}}>
<button
type="button"
onClick={() => setDetail((value) => !value)}
className="tap w-full border text-[length:var(--fs-sm)] font-bold"
style={{borderColor: ITEM_BORDER}}
>
{detail ? '접기' : '지도 · 설명 보기'}
</button>
<div hidden={!detail} className="pt-2.5">
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</article>
);
}
/** 기간이 비어 있는 일정은 한 자리에 모은다 — 빈 이름의 탭을 세우지 않는다. */
const NO_DURATION = '그 밖의 일정';
export function ItinerarySection() {
const payload = useSite();
const parsed = sectionItems<ItineraryItem>(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<string | null>(null);
const active = picked ?? tabs[0];
if (items.length === 0) return null;
return (
<ItemSection
id="itinerary"
name={parsed.title || sectionName(payload, 'itinerary', '추천 일정')}
subtitle={parsed.subtitle}
count="시각은 출발 시각과 머무는 시간으로 계산한 것입니다"
>
{tabs.length > 1 && (
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="묵는 기간">
{tabs.map((tab) => {
const on = active === tab;
return (
<button
key={tab}
type="button"
role="tab"
aria-selected={on}
onClick={() => setPicked(tab)}
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-100"
style={
on
? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)', borderColor: 'transparent'}
: undefined
}
>
{tab}
<span className="ml-1.5 font-normal opacity-100">
{items.filter((item) => (item.duration?.trim() || NO_DURATION) === tab).length}
</span>
</button>
);
})}
</div>
)}
{/*
★ 탭 하나 = 레일 하나 (2026-09-09, 사장님: "추천일정 1열로 만들어서 auto play")
예전에는 안(코스)마다 제목 + 레일을 두고 세로로 쌓았다. 한 탭에 안이 셋이면
카드 높이가 그대로 세 번 쌓여 이 섹션 하나가 화면 세 개를 먹었다 —
손님은 두 번째 안을 보려고 첫 안의 시간표를 다 지나야 했다.
★ 접힌 탭은 폭이 0 이라 embla 가 스냅을 잘못 잡는다 → key 로 활성 탭이 바뀔 때 다시 태운다.
*/}
{tabs.map((tab) => (
<div key={tab} hidden={tab !== active}>
<DurationRail
key={active}
tab={tab}
items={items.filter((item) => (item.duration?.trim() || NO_DURATION) === tab)}
placeName={payload.place.name}
/>
</div>
))}
</ItemSection>
);
}
/**
* 기간 탭 하나의 레일 — **코스 줄 + 날짜 카드 한 줄**.
*
* ★ 왜 코스 줄이 필요한가 (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 && (
<ul className="border-line mb-5 flex flex-wrap items-center gap-x-4 gap-y-2 border-b pb-3">
{courses.map(({item, start}) => {
const on = start === currentStart;
return (
<li key={`${item.name}-${start}`}>
<button
type="button"
onClick={() => api.current?.scrollTo(start)}
aria-current={on}
className={`text-left text-[length:var(--fs-sm)] underline-offset-4 transition-opacity hover:opacity-100 ${
on ? 'font-bold underline' : 'opacity-100 hover:underline'
}`}
>
{item.name}
</button>
</li>
);
})}
</ul>
)}
<Rail label={`${tab} 일정`} onSelect={setSlide} onReady={onReady}>
{courses.flatMap(({item, start}) => {
const days = daysOf(item);
// ★ 같은 코스의 날짜 카드는 붙여서 한 판처럼 잇는다 — 앞 카드 오른쪽 테두리를
// 점선으로, 뒷 카드 왼쪽 테두리는 지운다(대표: "같은 일정이면 띄어놓지 말라고").
// 코스가 갈리는 경계에는 이 처리가 없어 레일 위에서 그대로 구분된다.
return days.map((day, dayIndex) => (
<DayCard
key={`${item.name}-${start}-${dayIndex}`}
item={item}
badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`}
startTime={day.startTime}
stops={day.stops}
first={dayIndex === 0}
placeName={placeName}
continuesFrom={dayIndex > 0}
continuesTo={dayIndex < days.length - 1}
/>
));
})}
</Rail>
</>
);
}