목업(/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
163 lines
7.8 KiB
TypeScript
163 lines
7.8 KiB
TypeScript
/**
|
|
* 계절별 축제.
|
|
*
|
|
* ★ 왜 계절로 묶나 — 지역 축제는 날짜보다 '언제쯤'이 먼저다. 손님은 "가을에 뭐 있나"를 묻지
|
|
* "10월 3일에 뭐 있나"를 묻지 않는다. 그리고 정적 페이지는 몇 달 산다 — 날짜로 줄 세우면
|
|
* 구운 다음 날부터 지난 목록이 된다.
|
|
* ★ **전 계절을 HTML 에 굽고 화면에서만 접는다.** 접힌 계절이 HTML 에 없으면 검색·AI 가
|
|
* 나머지를 못 읽는다(items 규칙과 같다).
|
|
* ★ 링크는 검색으로 보낸다 — 축제 공식 페이지 주소를 우리가 지어내지 않는다.
|
|
*/
|
|
import {useState} from 'react';
|
|
import {ArrowUpRight, MapPin} from 'lucide-react';
|
|
import {useSite} from '@/lib/site-context';
|
|
import {sectionName} from '@/lib/derive';
|
|
|
|
const SEASONS = ['봄', '여름', '가을', '겨울'] as const;
|
|
type Season = (typeof SEASONS)[number] | '전체';
|
|
|
|
/** '3월' → 봄. 월을 못 읽으면 어느 계절로도 넣지 않는다(지어내지 않는다). */
|
|
function seasonOf(month?: string): (typeof SEASONS)[number] | null {
|
|
const n = Number((month ?? '').replace(/[^0-9]/g, ''));
|
|
if (!Number.isFinite(n) || n < 1 || n > 12) return null;
|
|
if (n >= 3 && n <= 5) return '봄';
|
|
if (n >= 6 && n <= 8) return '여름';
|
|
if (n >= 9 && n <= 11) return '가을';
|
|
return '겨울';
|
|
}
|
|
|
|
export function FestivalSection() {
|
|
const payload = useSite();
|
|
const festivals = payload.local.festivals ?? [];
|
|
const [active, setActive] = useState<Season>('전체');
|
|
const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
|
|
|
|
if (festivals.length === 0) return null;
|
|
|
|
const grouped = SEASONS.map((season) => ({
|
|
season,
|
|
items: festivals.filter((f) => seasonOf(f.month) === season),
|
|
})).filter((group) => group.items.length > 0);
|
|
const unknown = festivals.filter((f) => seasonOf(f.month) === null);
|
|
|
|
return (
|
|
<section
|
|
id="festival"
|
|
aria-labelledby="festival-heading"
|
|
className="border-line paper w-full border-b"
|
|
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
|
|
>
|
|
<div className="shell">
|
|
<header className="mb-6 sm:mb-8">
|
|
<h2 id="festival-heading" className="h2 flex items-center gap-2.5">
|
|
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
|
|
<span className="min-w-0">{sectionName(payload, 'festival', '계절별 축제')}</span>
|
|
</h2>
|
|
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
|
|
{region}의 축제와 행사를 계절로 묶었습니다.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="계절">
|
|
{[...grouped.map((g) => g.season), '전체' as const].map((season) => (
|
|
<button
|
|
key={season}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={season === active}
|
|
onClick={() => setActive(season)}
|
|
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
|
|
style={
|
|
season === active
|
|
? {
|
|
backgroundColor: 'var(--tpl-text, #09090b)',
|
|
color: 'var(--tpl-bg, #ffffff)',
|
|
borderColor: 'var(--tpl-text, #09090b)',
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{season}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="space-y-8">
|
|
{[...grouped, ...(unknown.length > 0 ? [{season: '그 밖에' as const, items: unknown}] : [])].map(
|
|
(group) => (
|
|
<div key={group.season} hidden={active !== '전체' && active !== group.season}>
|
|
<h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
|
|
{group.season}
|
|
</h3>
|
|
<ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 lg:gap-4">
|
|
{group.items.map((festival) => (
|
|
<li key={festival.name}>
|
|
<a
|
|
href={`https://search.naver.com/search.naver?query=${encodeURIComponent(festival.searchQuery)}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
className="panel group flex h-full overflow-hidden transition-opacity hover:opacity-85 sm:flex-col"
|
|
>
|
|
{festival.imageUrl && (
|
|
<span className="relative block aspect-square w-28 shrink-0 overflow-hidden sm:aspect-4/3 sm:w-auto">
|
|
<img
|
|
src={festival.imageUrl}
|
|
alt={`${festival.name} 사진`}
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
/>
|
|
<span
|
|
className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
|
|
style={{
|
|
backgroundColor:
|
|
'color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)',
|
|
color: 'var(--tpl-bg, #fff)',
|
|
}}
|
|
>
|
|
{festival.month}
|
|
</span>
|
|
</span>
|
|
)}
|
|
<span className="flex min-w-0 flex-1 flex-col gap-1 p-3 sm:p-3.5">
|
|
<span className="text-[length:var(--fs-sm)] font-bold">{festival.name}</span>
|
|
{/* ★ 기간은 있을 때만. 지난 날짜를 걸어두면 그것도 틀린 안내다. */}
|
|
{festival.period && (
|
|
<span className="text-[length:var(--fs-xs)] font-medium opacity-80">
|
|
{festival.period}
|
|
</span>
|
|
)}
|
|
{!festival.imageUrl && (
|
|
<span className="text-[length:var(--fs-xs)] font-bold opacity-60">
|
|
{festival.month}
|
|
</span>
|
|
)}
|
|
{festival.location && (
|
|
<span className="text-muted flex items-start gap-1 text-[length:var(--fs-xs)]">
|
|
<MapPin className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
|
<span>{festival.location}</span>
|
|
</span>
|
|
)}
|
|
{festival.description && (
|
|
<span className="text-muted line-clamp-2 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed sm:line-clamp-3">
|
|
{festival.description}
|
|
</span>
|
|
)}
|
|
<span className="text-muted mt-auto hidden items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100 sm:flex">
|
|
<span>검색으로 열기</span>
|
|
<ArrowUpRight className="size-3" aria-hidden="true" />
|
|
</span>
|
|
</span>
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
),
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|