목업(/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
211 lines
9.7 KiB
TypeScript
211 lines
9.7 KiB
TypeScript
/**
|
||
* 추천 일정 — 며칠 묵느냐로 갈린다.
|
||
*
|
||
* ★ 마크업은 목업(/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>
|
||
);
|
||
}
|