o2o-site-AEO/solution/site/src/layouts/paper/Itinerary.tsx
Mina Choi 765b9b6463 [feat] solution/site: 고택(paper) 레이아웃을 /s/stay2 시안과 같게 — 탭 4개 · stay2 마크업 이식
고택이 stay2 DOM 이식 1차에 머물러 화면 전체 폭·다른 머리글로 나가 시안과 달랐다.

- layouts/paper/paper.css: stay2 CSS 를 .w4p 안으로 옮겼다(@layer components, Tailwind 유틸이 이긴다)
- layouts/paper: 머리글·탭·첫 화면·날씨·블로그·소개·객실·사진·영상·주변·예약·이용안내·오시는 길·FAQ·축제·일정·지역 이야기·예약 요청 창
- stay2 하위 페이지(지역 소개·이용안내·이야기)는 #/area · #/stay · #/story 탭 전환, 내용은 한 HTML 에 둔다
- 공용 섹션에서 계산 함수만 꺼내 쓴다: infoView · useBookingRequest · StayBookingDemo 달력 함수
- SectionList: 레이아웃이 blog · reviews · postcardMaker 자리를 바꿀 수 있다
- templates.json paper: 보조색 #5b564e · 본문 고딕 · 모서리 4px · 간격 2.5rem(stay2 값)
- app.test: 고택 Frame 표식을 새 클래스(w4p)로

머뭄 데이터로 구워 1280·390px 에서 stay2 와 나란히 비교(첫 페이지 4855/4857px).
site tsc·eslint·vitest 105 passed, frontend vite build 통과

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:21 +09:00

104 lines
4.0 KiB
TypeScript

import {useState} from 'react';
import type {ItineraryItem, PlannerStop} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {sectionItems, sectionName} from '@site/lib/derive';
import {Sheet} from './Sheet';
const FIRST = 4;
function stopsOf(item: ItineraryItem): PlannerStop[] {
return item.days?.[0]?.stops ?? item.stops ?? [];
}
export function Itinerary() {
const payload = useSite();
const parsed = sectionItems<ItineraryItem>(payload, 'itinerary');
const [tag, setTag] = useState('');
const [open, setOpen] = useState<ItineraryItem | null>(null);
const items = parsed.items;
if (items.length === 0) return null;
const tags = [...new Set(items.map((item) => item.duration).filter((d): d is string => Boolean(d)))];
const shown = items.filter((item) => !tag || item.duration === tag);
const card = (item: ItineraryItem) => (
<button
key={item.name}
type="button"
className="itemcard"
onClick={() => setOpen(item)}
style={{padding: '16px 18px', border: '1px solid var(--line)', marginBottom: 10}}
>
<p style={{margin: '0 0 4px', fontSize: '15.5px', fontWeight: 500}}>{item.name}</p>
<p className="cap dim" style={{margin: '0 0 8px'}}>
{[item.duration, item.audience].filter(Boolean).join(' · ')}
</p>
{item.why && <p style={{margin: '0 0 8px', fontSize: '16.5px', lineHeight: 1.8}}>{item.why}</p>}
<p className="note" style={{margin: 0}}>
{stopsOf(item)
.slice(0, 4)
.map((stop) => [stop.name.split(' · ').pop(), stop.minutes && `${stop.minutes}분`].filter(Boolean).join(' '))
.join(' → ')}
</p>
</button>
);
return (
<>
<section className="sec" id="itinerary" aria-labelledby="itinerary-heading">
<p className="eyebrow">ITINERARY</p>
<h2 id="itinerary-heading">
{parsed.title || sectionName(payload, 'itinerary', '추천 일정')} <span>{items.length}</span>
</h2>
<p className="note" style={{margin: '-8px 0 14px'}}>
{parsed.subtitle || '하루를 어떻게 쓸지 미리 짜 두었습니다.'}
</p>
{tags.length > 1 && (
<div className="tabrow" role="tablist">
{['', ...tags].map((t) => (
<button key={t || 'all'} type="button" role="tab" aria-current={t === tag} onClick={() => setTag(t)}>
{t || '전체'} {items.filter((item) => !t || item.duration === t).length}
</button>
))}
</div>
)}
{shown.slice(0, FIRST).map(card)}
{shown.length > FIRST && (
<details>
<summary className="more" style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
{shown.length - FIRST}개 더 보기
</summary>
{shown.slice(FIRST).map(card)}
</details>
)}
</section>
<Sheet open={open != null} onClose={() => setOpen(null)} label={open?.name ?? ''}>
{open && (
<>
<h3>{open.name}</h3>
<p className="meta">{[open.duration, open.audience].filter(Boolean).join(' · ')}</p>
{open.why && <p>{open.why}</p>}
{(open.days?.length ? open.days : [{label: undefined, stops: open.stops}]).map((day, i) => (
<div key={i}>
{day.label && <p className="meta">{day.label}</p>}
<ul className="stops">
{(day.stops ?? []).map((stop, j) => (
<li key={j}>
<b>{stop.name}</b>
{(stop.note || stop.minutes) && (
<span>
{[stop.minutes && `${stop.minutes}분 머뭅니다`, stop.note].filter(Boolean).join(' · ')}
</span>
)}
</li>
))}
</ul>
</div>
))}
</>
)}
</Sheet>
<div className="rule" />
</>
);
}