o2o-site-AEO/solution/site/src/sections/items/ItinerarySection.tsx
Mina Choi 00e13bca7f [feat] solution/site,shared: 발행본을 /s/stay 목업에 맞춘다 — 잃어버린 CSS 토큰 복원 + 섹션 마크업 이식
목업(/s/stay)이 참조하는 스타일시트가 서버에서 사라져(404) 그 화면을 기준으로 삼을 수
없었다. 브라우저 캐시에 남아 있던 규칙을 꺼내 레포에 되돌리고, 섹션 마크업을 목업 HTML
에서 그대로 옮겼다. Playwright 대조로 섹션 래퍼·제목 16/17, 안쪽 구조 14/17 일치.

- index.css: --fs-display~--fs-xs(유동 타이포 7) · --section-space · --color-line/muted ·
  .h2 .h3 .panel .paper .measure .border-line .text-muted .divide-line .slider-viewport/track
  ★ 이 토큰이 없던 동안 컴포넌트가 var(--fs-display)를 써도 브라우저가 조용히 무시했다 —
    히어로 제목이 본문 크기로 나오던 원인
- HeroSection: 가운데 → 좌하단. 사진 위에 글자를 한가운데 얹으면 피사체를 정확히 가린다
- AnswerBlock·EssentialInfo·Units·Gallery·Location·Faq·LocalGuide·Weather·About:
  목업 마크업 그대로. embla 를 scroll-snap 으로 바꿔 스크립트 없이도 레일이 밀린다
- FestivalSection(계절 탭) · StorySection(이야기 탭 래퍼) · items/TripMap(OSM 타일 지도) 신설
  ★ 지도는 iframe 이 아니라 타일을 직접 깐다 — iframe 은 핀을 하나밖에 못 찍어
    "어떤 순서로 도는가" 를 그릴 수 없다
- items: itinerary·event·video kind 추가(파서에 없어 payload 까지 실려 오고도 화면에서
  사라지던 것) · Rail 을 slider-viewport/track 으로 · SongsSection 을 턴테이블로
- color.ts: 선 색을 secondary → mix(bg, text, .2). secondary 는 '본문 다음으로 진한
  글자색' 이라 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다
- seo/head.ts: --tpl-texture. 없으면 색만 갱지고 면은 매끈해서 인쇄물로 안 보인다
- seo/verify.ts: unitCode·numberOfRooms 를 화면 대조에서 뺀다
  ★ unitCode 는 ㎡의 ISO 코드로 priceCurrency 와 같은 성격인데 예외에 없었다. 그래서
    room_size 가 있는 사업장은 발행 게이트가 전부 막았다(실측: 가은채 객실 12개).
    stay 는 객실 2개 + room_size 없음이라 우연히 통과했다
- shared: LocalPlace.imageUrl/distanceMeters · FestivalEntry.imageUrl ·
  ItineraryStop 좌표 · TemplateLook.texture · people/chronicle/postcard imageUrl
  ★ 좌표·사진은 모델이 만드는 칸이 아니다. 공식 API 로 조회해 채운다

검증: tsc 통과 · Playwright 대조(목업 CSS 주입) 섹션 래퍼·제목 16/17, 안쪽 14/17.
남은 차이는 데이터 한계다 — people 사진은 위키에 원본이 없고(original:false),
weather.note 는 정적 페이지에 날씨 문장을 박는 문제라 보류.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP1ykMnWZFtLFpm2mow1Sw
2026-09-08 09:53:19 +09:00

211 lines
9.7 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.

/**
* 추천 일정 — 며칠 묵느냐로 갈린다.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="itinerary">`)에서 그대로 옮겼다.
* 기간 탭 → 일정마다 날짜 카드를 가로로 밀고, 카드 안에 지도와 타임라인이 함께 있다.
* ★ 시각은 **계산한다.** 출발 시각과 머무는 분을 더해 나가므로, 출발을 당기면 하루가
* 통째로 밀린다. 모델이 시각을 적어 내면 합이 안 맞는 표가 나온다.
* ★ 전 일정을 HTML 에 편다. 탭은 화면에서만 접는다 — 접힌 쪽이 HTML 에 없으면
* 검색·AI 가 나머지 일정을 못 읽는다.
*/
import {useState} from 'react';
import type {ItineraryDay, ItineraryItem, ItineraryStop} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine, tally} from './common';
import {TripMap, type TripPoint} from './TripMap';
/** 'HH:MM' → 분. 형식이 아니면 null(시각을 지어내지 않는다). */
function toMinutes(time?: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec((time ?? '').trim());
if (!m) return null;
return Number(m[1]) * 60 + Number(m[2]);
}
/** '3시간 40분' — 첫 칸 시작부터 마지막 칸 끝까지. */
function spanLabel(rows: {from?: string; to?: string}[]): string | null {
const from = toMinutes(rows[0]?.from);
const to = toMinutes(rows[rows.length - 1]?.to);
if (from === null || to === null || to <= from) return null;
const total = to - from;
const h = Math.floor(total / 60);
const m = total % 60;
return [h ? `${h}시간` : '', m ? `${m}분` : ''].filter(Boolean).join(' ');
}
function toClock(minutes: number): string {
const h = Math.floor(minutes / 60) % 24;
return `${String(h).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
}
/** 정거장마다 시작·끝 시각을 계산한다. 출발 시각이 없으면 시각을 아예 안 그린다. */
function schedule(day: ItineraryDay): {stop: ItineraryStop; from?: string; to?: string}[] {
let cursor = toMinutes(day.startTime);
return (day.stops ?? []).map((stop) => {
if (cursor === null) return {stop};
const from = cursor;
const to = from + (stop.minutes ?? 0);
cursor = to;
return {stop, from: toClock(from), to: toClock(to)};
});
}
export function ItinerarySection() {
const payload = useSite();
const parsed = sectionItems<ItineraryItem>(payload, 'itinerary');
const durations = Array.from(
new Set(parsed.items.map((item) => item.duration?.trim()).filter(Boolean) as string[]),
);
const [active, setActive] = useState(durations[0] ?? '');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="itinerary"
name={parsed.title || sectionName(payload, 'itinerary', '추천 일정')}
subtitle={parsed.subtitle ?? '며칠 묵느냐에 따라 다르게 돕니다'}
count={`${tally(parsed.items.length, parsed.unverified, '개')} · 시각은 출발 시각과 머무는 시간으로 계산한 것입니다`}
>
{durations.length > 1 && (
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="묵는 기간">
{durations.map((duration) => (
<button
key={duration}
type="button"
role="tab"
aria-selected={duration === active}
onClick={() => setActive(duration)}
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
style={
duration === active
? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)', borderColor: 'transparent'}
: undefined
}
>
{duration}
<span className="ml-1.5 font-normal opacity-60">
{parsed.items.filter((i) => i.duration === duration).length}
</span>
</button>
))}
</div>
)}
<div className="space-y-10">
{parsed.items.map((plan, planIndex) => (
<div
key={`${plan.name}-${planIndex}`}
hidden={durations.length > 1 && plan.duration !== active}
className="space-y-3"
>
<div className="space-y-1">
<h3 className="serif text-lg font-bold">{plan.name}</h3>
{plan.audience && <p className="text-[13px] opacity-60">{plan.audience}</p>}
</div>
<div className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3">
{(plan.days ?? []).map((day, dayIndex) => {
const rows = schedule(day);
const last = rows[rows.length - 1];
const points: TripPoint[] = (day.stops ?? [])
.filter((s): s is ItineraryStop & {latitude: number; longitude: number} =>
typeof s.latitude === 'number' && typeof s.longitude === 'number')
.map((s) => ({name: s.name, latitude: s.latitude, longitude: s.longitude, searchQuery: s.searchQuery}));
return (
<article
key={`${day.label}-${dayIndex}`}
className="w4-paper w-[320px] shrink-0 snap-center border"
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}}
>
<span
className="border px-2 py-0.5 text-[11px] font-bold"
style={{
backgroundColor: 'var(--tpl-text, #09090b)',
color: 'var(--tpl-bg, #ffffff)',
borderColor: 'var(--tpl-text, #09090b)',
}}
>
{day.label}
</span>
{rows[0]?.from && last?.to && (
<span className="text-[11px] opacity-60">
{rows[0].from}–{last.to}
{spanLabel(rows) && ` · ${spanLabel(rows)}`}
</span>
)}
</div>
<div className="space-y-1.5 px-4 pt-3.5">
<h4 className="serif text-lg font-bold">{plan.name}</h4>
{plan.why && (
<p className="text-[13px] leading-relaxed opacity-80">{plan.why}</p>
)}
</div>
{/* 지도는 카드 폭을 꽉 채운다 — 여백을 두면 접힌 카드에서 지도가 잘려 보인다. */}
{points.length > 0 && <TripMap points={points} />}
<ol className="mt-3 px-4 pb-3">
{rows.map(({stop, from, to}, stopIndex) => (
<li
key={`${stop.name}-${stopIndex}`}
className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5"
>
<span className="serif pt-2 text-[12px] tabular-nums opacity-75">{from ?? ''}</span>
<div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}>
<div className="flex items-start gap-2.5 pt-1">
{stop.imageUrl && (
<img
src={stop.imageUrl}
alt={`${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-sm font-bold">
<span
className="mr-1.5 inline-grid size-[17px] translate-y-px place-items-center rounded-full text-[10px] tabular-nums"
style={{backgroundColor: ITEM_ACCENT, color: 'var(--tpl-bg, #ffffff)'}}
>
{stopIndex + 1}
</span>
{stop.name}
</span>
{stop.note && (
<span className="mt-0.5 block text-[12px] leading-relaxed opacity-75">
{stop.note}
</span>
)}
</span>
</div>
<p className="mt-0.5 text-[10px] opacity-50">
{from && to ? `${from}–${to}` : ''}
{stop.searchQuery && ` · 지도 검색 ${stop.searchQuery}`}
</p>
</div>
</li>
))}
</ol>
<div className="border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}>
<SourceLine source={plan.source} verified={plan.verified} />
</div>
</article>
);
})}
</div>
</div>
))}
</div>
</ItemSection>
);
}