모달 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>
373 lines
18 KiB
TypeScript
373 lines
18 KiB
TypeScript
/**
|
||
* 추천 일정 — 발행본. **계절별 추천 하루와 같은 승차권 카드**로 그린다.
|
||
*
|
||
* ★ 왜 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>
|
||
</>
|
||
);
|
||
}
|