[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
This commit is contained in:
Mina Choi 2026-09-08 09:53:19 +09:00
parent 6df125d840
commit 00e13bca7f
38 changed files with 2085 additions and 774 deletions

View File

@ -33,6 +33,13 @@ export function deriveSurfaces(colors: {bg: string; card: string; text: string;
surface: mix(colors.bg, colors.text, 0.06),
surfaceAlt: colors.card,
inverse: '#1c1917',
border: colors.secondary,
/**
* 선 색.
*
* ★ secondary 를 그대로 쓰면 안 된다 — 그건 '본문 다음으로 진한 글자색' 이라
* 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다(갱지 팔레트에서 특히).
* 바탕에서 글자색 쪽으로 20%만 간 값이 목업의 선 색이다(#e4dac0 → #bcb49e).
*/
border: mix(colors.bg, colors.text, 0.2),
};
}

View File

@ -85,10 +85,16 @@ export interface PeopleItem {
role?: string;
oneLine?: string;
/**
* 사진 검색어. ★ 이미지 URL 을 받지 않는다 — 초상권·저작권 확인은 사장님 몫이고,
* 모델이 지어낸 주소를 링크하면 깨진 사진이 인물 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약).
* 사진 검색어. 모델은 이 값만 만든다 — 주소를 지어내면 깨진 사진이 인물 얼굴 자리에 남는다.
*/
imageQuery?: string;
/**
* 초상 사진.
*
* ★ **모델이 채우는 칸이 아니다.** 권리가 확인된 출처(위키미디어 등)에서 사람이 넣거나,
* 수집기가 라이선스를 확인하고 넣는다. 초상권은 지어낸 URL 로 감당할 수 없다.
*/
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -101,6 +107,8 @@ export interface ChronicleItem {
place?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean;
/** 사진. 재게시 권리가 확실한 출처(공공누리·위키미디어)만 싣는다. 없으면 글자만 나간다. */
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -124,6 +132,8 @@ export interface PostcardItem {
place?: string;
/** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */
postmark?: string;
/** 엽서 앞면 사진. 권리가 확실한 출처만. 없으면 글자만 있는 뒷면 한 장이 된다. */
imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@ -138,6 +148,56 @@ export interface QuizItem {
source?: DataSource;
}
export interface ItineraryStop {
name: string;
minutes?: number;
note?: string;
searchQuery?: string;
/**
* 좌표.
*
* ★ **모델이 만드는 값이 아니다.** 지도에 핀을 찍는 값이라 한 자리만 틀려도 손님이
* 엉뚱한 데로 간다. 장소명으로 공식 API(TourAPI·카카오)를 조회해 채운다.
* 없으면 지도를 그리지 않고 타임라인만 낸다.
*/
latitude?: number;
longitude?: number;
/** 사진. 권리가 확실한 출처만. */
imageUrl?: string;
}
export interface ItineraryDay {
label: string;
startTime?: string;
stops?: ItineraryStop[];
}
/** 며칠 묵느냐로 갈리는 일정. days 가 하루씩이다. */
export interface ItineraryItem {
name: string;
duration?: string;
audience?: string;
why?: string;
days?: ItineraryDay[];
verified?: DataVerified;
source?: DataSource;
}
/** 사장님이 쓰는 공지·소식. 지역 리서치가 아니라 업소가 소유한 글이다. */
export interface EventItem {
kind?: string;
title: string;
summary?: string;
body?: string;
verified?: DataVerified;
}
export interface VideoItem {
url: string;
caption?: string;
verified?: DataVerified;
}
export interface PlannerStop {
name: string;
/** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */
@ -181,6 +241,10 @@ export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = {
postcard: 'line',
quiz: 'question',
planner: 'name',
// ★ 여기 없는 kind 는 파서가 통째로 건너뛴다 — payload 에 실려 와도 화면에서 사라진다.
itinerary: 'name',
event: 'title',
video: 'url',
};
export interface ParsedSectionData<T> {

View File

@ -106,6 +106,13 @@ export interface TemplateLook {
headingWeight: string;
/** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */
sectionSpace: string;
/**
* 종이 질감 — `background-image` 에 그대로 들어간다(`.paper`).
*
* ★ 레트로(갱지)의 인상이 사실상 이 값에서 온다. 없으면 색만 갱지고 면은 매끈해서
* 같은 팔레트인데도 인쇄물로 안 보인다. 비워 두면 아무것도 깔지 않는다.
*/
texture?: string;
}
export interface PhotoItem {

View File

@ -189,6 +189,14 @@ export interface LocalPlace {
description?: string;
/** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */
searchQuery: string;
/**
* 대표 사진. 재게시 권리가 확실한 출처(공공누리 등)에서 온 것만 싣는다.
*
* ★ 없으면 카드가 글자만 남는다 — 그래도 지어내지 않는다.
*/
imageUrl?: string;
/** 사업장에서 잰 직선거리(m). 도보 시간을 화면에서 환산하는 근거다. */
distanceMeters?: number;
}
export interface FestivalEntry {
@ -199,6 +207,8 @@ export interface FestivalEntry {
description?: string;
officialUrl?: string;
searchQuery: string;
/** 대표 사진. 권리가 확실한 출처만 — 없으면 카드가 글자만 남는다. */
imageUrl?: string;
}
export interface RouteEntry {

View File

@ -15,6 +15,28 @@
--tpl-card: #fafafa;
--tpl-text: #09090b;
--tpl-accent: #2563eb;
/*
* 유동 타이포 — 화면 폭에 따라 자라는 글자 크기.
*
* ★ 이 토큰이 없던 동안 히어로 제목이 본문과 같은 크기로 나왔다. 섹션 컴포넌트들이
* var(--fs-display) 를 쓰는데 정의가 어디에도 없어 그냥 무시됐다(브라우저는 조용히 넘어간다).
* ★ 값은 목업(/s/stay)이 쓰던 것을 그대로 옮겼다 — 화면이 달라지면 목업과 어긋난다.
*/
--fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem);
--fs-h2: clamp(1.3125rem, 1.15rem + 0.7vw, 1.75rem);
--fs-h3: clamp(1.0625rem, 1rem + 0.3vw, 1.25rem);
--fs-lead: clamp(0.9375rem, 0.9rem + 0.22vw, 1.0625rem);
--fs-body: clamp(0.9375rem, 0.91rem + 0.15vw, 1rem);
--fs-sm: clamp(0.8125rem, 0.8rem + 0.1vw, 0.875rem);
--fs-xs: clamp(0.75rem, 0.74rem + 0.06vw, 0.78125rem);
/* 섹션 세로 여백. 템플릿의 sectionSpace 를 상한으로 두고 좁은 화면에서만 줄인다. */
--section-space: clamp(2.25rem, 5vw, var(--tpl-section-space, 3.5rem));
/* 선·흐린 글자 — 템플릿 색에서 유도한다. 고정 회색을 쓰면 갱지 바탕에서 뜬다. */
--color-line: var(--tpl-border, color-mix(in oklab, var(--tpl-text, #09090b) 14%, transparent));
--color-muted: color-mix(in oklab, var(--tpl-text, #09090b) 60%, transparent);
}
@theme inline {
@ -68,4 +90,91 @@ body {
padding-inline: 1.5rem;
}
}
/* 본문 한 줄 길이 상한. 68자를 넘기면 눈이 다음 줄을 못 찾는다. */
.measure {
max-width: 68ch;
}
/* 종이 질감 — 템플릿이 texture 를 주면 깔린다(레트로 갱지). 없으면 아무것도 안 깔린다. */
.paper {
background-image: var(--tpl-texture, none);
}
/* 카드·패널. 바탕보다 한 겹 앞으로 나온 면. */
.panel {
background-color: color-mix(in oklab, currentcolor 5%, transparent);
border: var(--tpl-border-width, 1px) solid var(--color-line);
border-radius: var(--tpl-radius, 0.75rem);
box-shadow: var(--tpl-shadow, none);
}
.panel-sunken {
background-color: color-mix(in oklab, currentcolor 6%, transparent);
}
.border-line {
border-color: var(--color-line);
}
/* divide-y 의 사이 선도 같은 색으로. Tailwind 기본은 회색이라 갱지 위에서 뜬다. */
.divide-line > :not([hidden]) ~ :not([hidden]) {
border-color: var(--color-line);
}
.text-muted {
color: var(--color-muted);
}
/* 소제목. h1 은 히어로가 인라인으로 쓰고, 그 아래 단계는 이 두 클래스가 맡는다. */
.h2 {
font-family: var(--tpl-font-heading, var(--font-serif));
font-size: var(--fs-h2);
font-weight: var(--tpl-heading-weight, 700);
letter-spacing: var(--tpl-heading-tracking, -0.01em);
line-height: 1.2;
}
.h3 {
font-family: var(--tpl-font-heading, var(--font-serif));
font-size: var(--fs-h3);
font-weight: var(--tpl-heading-weight, 700);
letter-spacing: var(--tpl-heading-tracking, -0.005em);
line-height: 1.35;
}
/* 간판체처럼 굵기가 한 벌뿐인 서체에 가짜 볼드가 씌워지는 것을 막는다. */
.serif,
.h2,
.h3 {
font-synthesis-weight: none;
}
.label {
font-size: var(--fs-xs);
color: var(--color-muted);
font-weight: 600;
}
.tpl-shadow {
box-shadow: var(--tpl-shadow, none);
}
}
@layer components {
/*
* 가로 레일 — 목업과 같은 클래스 이름. 캐러셀 라이브러리 없이 스크롤 스냅으로만 만든다.
* ★ 스크립트가 없어도 손가락·트랙패드로 밀린다. 라이브러리를 얹으면 그게 죽었을 때
* 레일 전체가 한 줄로 굳는다.
*/
.slider-viewport {
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.75rem;
}
.slider-viewport::-webkit-scrollbar {
display: none;
}
.slider-track {
display: flex;
scroll-snap-type: x mandatory;
}
.slider-track > * {
scroll-snap-align: center;
}
}

View File

@ -5,6 +5,7 @@ import {
EssentialInfoSection,
ExhibitionSection,
FaqSection,
FestivalSection,
GallerySection,
HeroSection,
InquirySection,
@ -14,11 +15,20 @@ import {
LocationSection,
RulesSection,
SpaceSection,
StorySection,
UnitsSection,
} from '@/sections';
import {useSite} from '@/lib/site-context';
import {isSectionEnabled} from '@/lib/derive';
/**
* StorySection 이 탭으로 묶어 그리는 아이템.
*
* ★ `sections/StorySection.tsx` 의 STORY_KINDS 와 같은 목록이어야 한다.
* 어긋나면 어떤 아이템은 탭 밖에 따로 서고, 어떤 아이템은 어디에도 안 나온다.
*/
const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily'];
/**
* 홈.
*
@ -59,12 +69,16 @@ export function HomePage() {
exhibition: ExhibitionSection,
photos: GallerySection,
local: LocalGuideSection,
festival: FestivalSection,
weather: WeatherSection,
map: LocationSection,
faq: FaqSection,
// 붙여넣기 아이템 아홉. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다.
// 붙여넣기 아이템. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다.
// ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다.
...ITEM_SECTIONS,
// ★ 지역 이야기(가요·인물·연표·엽서·퀴즈·문학·일력)는 StorySection 이 탭으로 묶어 그린다.
// 그래서 그 일곱은 위 표에서 **가려낸다** — 여기 남겨 두면 탭 안과 밖에 두 번 나온다.
...Object.fromEntries(STORY_KINDS.map((kind) => [kind, StorySection])),
};
const rendered = new Set<string>();

View File

@ -1,6 +1,13 @@
import {useSite} from '@/lib/site-context';
import {galleryImages, sectionBody} from '@/lib/derive';
/**
* 소개.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="about">`)에서 그대로 옮긴 것이다.
* 비슷하게 다시 짜지 않는다 — 그리드 비율(7:5)·모서리(rounded-xl)·본문 행간(1.8)이
* 조금만 달라도 같은 페이지로 안 보인다.
*/
export function AboutSection() {
const payload = useSite();
const {narrative, place} = payload;
@ -17,15 +24,14 @@ export function AboutSection() {
<section
id="about"
aria-labelledby="about-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider opacity-50">About</p>
<div className="grid grid-cols-1 items-center gap-8 md:grid-cols-12 lg:gap-12">
<div className="grid grid-cols-1 items-center gap-8 lg:grid-cols-12 lg:gap-14">
{image && (
<figure className="md:col-span-6">
<div className="relative aspect-4/3 overflow-hidden rounded-2xl md:aspect-5/4">
<figure className="lg:col-span-7">
<div className="tpl-border border-line relative aspect-4/3 max-h-[26rem] overflow-hidden rounded-xl border lg:aspect-3/2">
<img
src={image.url}
alt={image.alt}
@ -36,20 +42,16 @@ export function AboutSection() {
className="size-full object-cover"
/>
</div>
{image.caption && (
<figcaption className="mt-2 text-xs opacity-60">{image.caption}</figcaption>
)}
</figure>
)}
<div className={image ? 'md:col-span-6' : 'md:col-span-12'}>
{/* 제목은 사장님이 붙인 섹션 이름이 먼저다. 이름이 비어 있을 때만
지금까지 쓰던 문구(한 줄 소개 → "○○ 소개")로 떨어진다. */}
<h2 id="about-heading" className="serif mb-4 text-2xl font-bold leading-snug tracking-tight sm:text-3xl lg:text-4xl">
{narrative.heroSubline ?? `${place.name} 소개`}
<div className={image ? 'lg:col-span-5' : 'lg:col-span-12'}>
<h2 id="about-heading" className="h2 mb-6 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">{`${place.name} 소개`}</span>
</h2>
<div className="space-y-3 text-sm leading-relaxed opacity-80">
<div className="measure space-y-4 text-[length:var(--fs-lead)] leading-[1.8]">
{about.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}

View File

@ -11,6 +11,9 @@ import {formatKoreanDate} from '@/lib/format';
* 표(dl)로 두는 이유는 label-value 관계가 마크업으로 드러나야 기계가 짝을 짓기 때문이다.
*
* ★ 여기 나오는 값은 전부 확인된 fact 다. essentialRows() 가 걸러 준다.
* ★ 마크업은 목업(/s/stay 의 `<section id="summary">`)에서 그대로 옮겼다.
* 좌우 5:7 그리드에 왼쪽이 제목·요약, 오른쪽이 두 칸 표다. 제목은 h2 태그지만 h3 급으로
* 그린다 — 이 블록은 섹션이 아니라 히어로에 딸린 요약이라 같은 크기로 서면 위계가 무너진다.
*/
export function AnswerBlock() {
const payload = useSite();
@ -23,33 +26,36 @@ export function AnswerBlock() {
<section
id="summary"
aria-labelledby="summary-heading"
className="w-full border-b border-black/8 py-10 sm:py-12"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line w-full border-b py-10 sm:py-14"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
>
<div className="shell">
<h2 id="summary-heading" className="serif mb-1 text-lg font-bold sm:text-xl">
한눈에 보는 {payload.place.name}
</h2>
<p className="mb-5 text-xs opacity-60">
{formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다.
</p>
<div className="grid gap-8 lg:grid-cols-12 lg:gap-12">
<div className="lg:col-span-5">
<h2 id="summary-heading" className="h3">
한눈에 보는 {payload.place.name}
</h2>
{/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
<p className="measure mt-3 text-[length:var(--fs-body)] leading-relaxed">{summary}</p>
<p className="text-muted mt-3 text-[length:var(--fs-xs)]">
{formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다.
</p>
</div>
{/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
<p className="mb-5 max-w-3xl text-sm leading-relaxed sm:text-base">{summary}</p>
{rows.length > 0 && (
<dl className="grid grid-cols-1 gap-x-8 gap-y-0 sm:grid-cols-2">
{rows.map((row) => (
<div
key={row.label}
className="flex items-start justify-between gap-4 border-b border-black/8 py-2.5 text-sm"
>
<dt className="shrink-0 font-medium opacity-60">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
{rows.length > 0 && (
<dl className="grid grid-cols-1 gap-x-10 lg:col-span-7 xl:grid-cols-2">
{rows.map((row) => (
<div
key={row.label}
className="border-line flex flex-col gap-0.5 py-2.5 sm:flex-row sm:gap-4 sm:border-b"
>
<dt className="text-muted shrink-0 text-[length:var(--fs-sm)] sm:w-28">{row.label}</dt>
<dd className="text-[length:var(--fs-sm)] font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
</div>
</div>
</section>
);

View File

@ -28,8 +28,8 @@ export function BookingSection() {
<section
id="booking"
aria-labelledby="booking-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="paper border-line w-full border-b"
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -37,8 +37,9 @@ export function BookingSection() {
<span>Booking</span>
</p>
<h2 id="booking-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'booking', '예약 안내')}
<h2 id="booking-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, 'booking', '예약 안내')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
{payload.place.name} 예약은 아래 경로로 받습니다.

View File

@ -1,82 +1,137 @@
import {Info} from 'lucide-react';
import {Phone} from 'lucide-react';
import {selectPublishable} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {essentialRows} from '@/lib/derive';
import {bookingLinks, channelLabel, essentialRows} from '@/lib/derive';
import {formatKoreanDate} from '@/lib/format';
/**
* 이용 정보 표 전체.
* 이용안내 및 예약.
*
* AnswerBlock 이 상위 6개만 보여준다면 여기는 확인된 것 전부를 낸다.
* ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면
* 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다.
* ★ 마크업은 목업(/s/stay 의 `<section id="info">`)에서 그대로 옮겼다.
* ★ 표를 두 덩이로 가른다 — "예약 전 확인"(critical)과 "시설 · 편의".
* critical 은 틀리면 예약 클레임이 나는 항목이라 손님이 먼저 봐야 하고,
* 와이파이·주차와 같은 무게로 섞이면 그 위계가 사라진다.
* ★ 예약 버튼이 이 섹션 안에 있다. 규정을 읽은 자리에서 바로 예약으로 이어지지 않으면
* 손님은 다시 위로 올라가야 한다.
* ★ "확인 중인 항목 N개"를 숨기지 않는다 — 없는 척하면 손님이 다른 데서 틀린 값을 찾아 온다.
*/
export function EssentialInfoSection() {
const payload = useSite();
const rows = essentialRows(payload);
if (rows.length === 0) return null;
// critical 여부는 fact 에만 있다. 표 행(label/value)에 다시 붙여 둘로 가른다.
const criticalLabels = new Set(
selectPublishable(payload.facts)
.filter((fact) => fact.scope === 'place' && fact.critical)
.map((fact) => fact.label),
);
const groups = [
{title: '예약 전 확인', accent: true, rows: rows.filter((row) => criticalLabels.has(row.label))},
{title: '시설 · 편의', accent: false, rows: rows.filter((row) => !criticalLabels.has(row.label))},
].filter((group) => group.rows.length > 0);
const hiddenCount = payload.facts.filter(
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
).length;
if (rows.length === 0) return null;
const half = Math.ceil(rows.length / 2);
const links = bookingLinks(payload);
const phone = payload.place.phone;
return (
<section
id="info"
aria-labelledby="info-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<Info className="size-4" />
<span>Essential Information</span>
</p>
<div className="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h2 id="info-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl">
{'이용 및 예약 안내'}
</h2>
<p className="mt-1 text-xs opacity-60 sm:text-sm">
방문 전 확인이 필요한 운영 규정과 시설 안내입니다.
</p>
</div>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="info-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">이용안내 및 예약</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
방문 전 확인이 필요한 운영 규정과 시설 안내입니다.
</p>
</div>
</div>
</header>
</div>
<div
className="overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
>
<div className="grid grid-cols-1 divide-y divide-black/8 md:grid-cols-2 md:divide-x md:divide-y-0">
{[rows.slice(0, half), rows.slice(half)].map((column, columnIndex) => (
<dl key={columnIndex} className="divide-y divide-black/5">
{column.map((row) => (
<div className="space-y-8">
{groups.map((group) => (
<section key={group.title}>
<h3 className="border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
{group.accent && (
<i
aria-hidden="true"
className="h-[1em] w-[3px] shrink-0"
style={{backgroundColor: 'var(--color-accent)'}}
/>
)}
<span>{group.title}</span>
</h3>
<dl className="divide-line divide-y">
{group.rows.map((row) => (
<div
key={row.label}
className="flex flex-col justify-between gap-1 p-4 sm:flex-row sm:items-start sm:gap-4 sm:p-5"
className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6"
>
<dt className="shrink-0 text-xs font-bold sm:w-32 sm:text-sm">{row.label}</dt>
<dd className="flex-1 text-left sm:text-right">
<p className="text-xs font-medium sm:text-sm">{row.value}</p>
{row.note && <p className="mt-0.5 text-[11px] opacity-50">{row.note}</p>}
</dd>
<dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0">
{row.label}
</dt>
<dd className="measure text-[length:var(--fs-sm)] font-semibold">{row.value}</dd>
</div>
))}
</dl>
))}
</div>
</section>
))}
<div className="flex flex-col items-center justify-between gap-2 border-t border-black/8 px-4 py-3.5 text-xs opacity-60 sm:flex-row sm:px-6">
{(phone || links.length > 0) && (
<div className="border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[length:var(--fs-sm)] font-semibold">
{payload.place.name} 예약은 아래로 받습니다.
</p>
<div className="flex flex-wrap gap-2">
{phone && (
<a
href={`tel:${phone}`}
className="tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
>
<Phone className="size-4" aria-hidden="true" />
<span>{phone}</span>
</a>
)}
{links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{channelLabel(link)}로 예약
</a>
))}
</div>
</div>
)}
</div>
<p className="text-muted mt-5 text-[length:var(--fs-xs)]">
<span className="flex flex-col gap-1 sm:flex-row sm:justify-between">
<span>
{hiddenCount > 0
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
: '모든 항목이 사업자 확인을 거쳤습니다.'}
</span>
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span>
</div>
</div>
</span>
</p>
</div>
</section>
);

View File

@ -27,8 +27,8 @@ export function ExhibitionSection() {
<section
id="exhibition"
aria-labelledby="exhibition-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="paper border-line w-full border-b"
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -36,8 +36,9 @@ export function ExhibitionSection() {
<span>Exhibition</span>
</p>
<h2 id="exhibition-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'exhibition', '관람 안내')}
<h2 id="exhibition-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, 'exhibition', '관람 안내')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
방문 전 관람 조건을 확인해 주세요.

View File

@ -1,4 +1,3 @@
import {HelpCircle} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {faqList} from '@/lib/derive';
@ -20,36 +19,42 @@ export function FaqSection() {
<section
id="faq"
aria-labelledby="faq-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<HelpCircle className="size-4" />
<span>FAQ</span>
</p>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="faq-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">자주 묻는 질문</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
아래 답변은 모두 사업자가 확인한 내용입니다.
</p>
</div>
</div>
</header>
</div>
<h2 id="faq-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{'자주 묻는 질문'}
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
아래 답변은 모두 사업자가 확인한 내용입니다.
</p>
<div className="max-w-3xl space-y-3">
{/* ★ 두 칸으로 편다. 한 줄로 세우면 질문 열 개가 화면 두 개를 잡아먹는다.
`h-fit` 이라 펼친 카드만 길어지고 옆 카드는 따라 늘어나지 않는다. */}
<div className="grid gap-3 lg:grid-cols-2 lg:gap-4">
{faqs.map((faq, index) => (
<details
key={faq.faqId}
// 첫 항목만 펼쳐 둔다 — 나머지도 DOM 에는 그대로 있다.
open={index === 0}
className="overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
className="panel group h-fit overflow-hidden"
>
<summary className="flex cursor-pointer items-center gap-2.5 p-4 text-xs font-bold marker:content-none sm:p-5 sm:text-sm [&::-webkit-details-marker]:hidden">
<span className="serif shrink-0 opacity-50">Q.</span>
<span>{faq.question}</span>
<summary className="tap flex cursor-pointer items-center gap-3 px-5 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden">
<span className="serif text-muted shrink-0">Q.</span>
<span className="flex-1">{faq.question}</span>
<span className="text-muted shrink-0 transition-transform group-open:rotate-45">+</span>
</summary>
<div className="border-t border-black/5 px-4 pb-4 pt-3 text-xs leading-relaxed opacity-80 sm:px-5 sm:pb-5 sm:text-sm">
<div className="border-line border-t px-5 pb-5 pt-4 text-[length:var(--fs-sm)] leading-relaxed opacity-80">
{faq.answer}
</div>
</details>

View File

@ -0,0 +1,162 @@
/**
* 계절별 축제.
*
* ★ 왜 계절로 묶나 — 지역 축제는 날짜보다 '언제쯤'이 먼저다. 손님은 "가을에 뭐 있나"를 묻지
* "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>
);
}

View File

@ -1,38 +1,35 @@
import {useCallback, useEffect, useState} from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import {useRef, useState} from 'react';
import {ChevronLeft, ChevronRight, X} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {galleryImages} from '@/lib/derive';
import {galleryImages, sectionName} from '@/lib/derive';
/**
* 사진 갤러리.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="gallery">`)에서 그대로 옮겼다.
* ★ 좁은 화면은 가로로 미는 한 줄, `lg` 이상은 4열 격자다. 캐러셀 라이브러리를 쓰지 않는다 —
* `scroll-snap` 만으로 같은 동작이 나오고, 스크립트가 없어도 사진이 다 보인다.
* ★ 전 장을 HTML 에 편다. 접거나 지연 삽입하면 크롤러가 나머지를 못 읽는다.
*/
export function GallerySection() {
const payload = useSite();
const images = galleryImages(payload);
const setting = payload.theme.sections.find((section) => section.id === 'photos');
const variantId = setting?.variantId ?? 'photos.grid';
const railRef = useRef<HTMLUListElement>(null);
const [index, setIndex] = useState(0);
const [openIndex, setOpenIndex] = useState<number | null>(null);
/**
* ★ 슬라이드를 화면 폭보다 좁게 잡아 **양옆이 보이게** 한다.
* 100% 폭이면 정지 화면에서 사진 한 장과 구분이 안 된다 — 손님은 옆으로 더 있다는 걸
* 모르고 지나간다. 옆 사진이 살짝 걸쳐 보이는 것이 "밀어서 볼 수 있다"는 유일한 신호다.
*/
const [carouselRef, carouselApi] = useEmblaCarousel({loop: true, align: 'center', skipSnaps: false});
const [selected, setSelected] = useState(0);
// 화살표·드래그·키보드 어느 쪽으로 움직여도 인디케이터가 따라오게 한다.
useEffect(() => {
if (!carouselApi) return;
const sync = () => setSelected(carouselApi.selectedScrollSnap());
sync();
carouselApi.on('select', sync);
return () => {
carouselApi.off('select', sync);
};
}, [carouselApi]);
const scrollTo = useCallback((index: number) => carouselApi?.scrollTo(index), [carouselApi]);
if (images.length === 0) return null;
const onScroll = () => {
const rail = railRef.current;
if (!rail) return;
setIndex(Math.round(rail.scrollLeft / Math.max(1, rail.clientWidth)));
};
const move = (delta: number) => {
const rail = railRef.current;
if (!rail) return;
rail.scrollTo({left: (index + delta) * rail.clientWidth, behavior: 'smooth'});
};
const step = (delta: number) => {
setOpenIndex((current) =>
current === null ? null : (current + delta + images.length) % images.length,
@ -43,105 +40,91 @@ export function GallerySection() {
<section
id="gallery"
aria-labelledby="gallery-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider opacity-50">Gallery</p>
<h2 id="gallery-heading" className="serif mb-8 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{setting?.name || '공간 갤러리'}
</h2>
{/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */}
{variantId === 'photos.carousel' ? (
<div
className="relative mx-auto max-w-5xl"
role="group"
aria-roledescription="캐러셀"
aria-label={`사진 ${images.length}장`}
// 좌우 키로도 넘길 수 있어야 한다 — 마우스가 없는 사람에게 화살표 버튼만 남기지 않는다.
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') carouselApi?.scrollPrev();
if (event.key === 'ArrowRight') carouselApi?.scrollNext();
}}
>
{/* 양옆 사진이 걸쳐 보이도록 컨테이너 밖으로 넘치는 부분만 잘라낸다. */}
<div ref={carouselRef} className="overflow-hidden">
<ul className="-ml-3 flex touch-pan-y sm:-ml-4">
{images.map((image, index) => (
<li
key={image.mediaId}
className="min-w-0 flex-[0_0_88%] pl-3 sm:flex-[0_0_72%] sm:pl-4"
aria-roledescription="슬라이드"
aria-label={`${index + 1} / ${images.length}`}
>
<GalleryImage
image={image}
onOpen={() => setOpenIndex(index)}
// 지금 보고 있지 않은 사진은 눌러서 흐리게 — 어느 것이 현재인지 눈에 바로 들어온다.
className={`aspect-16/9 rounded-2xl transition-opacity duration-300 ${
index === selected ? 'opacity-100' : 'opacity-45'
}`}
/>
</li>
))}
</ul>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="gallery-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, 'photos', '사진 갤러리')}</span>
</h2>
</div>
</div>
</header>
</div>
{images.length > 1 && (
<>
<CarouselButton label="이전 사진" side="left" onClick={() => carouselApi?.scrollPrev()} />
<CarouselButton label="다음 사진" side="right" onClick={() => carouselApi?.scrollNext()} />
{/* 몇 장 중 몇 번째인지. 점만 있으면 사진이 많을 때 셀 수 없다. */}
<div className="mt-4 flex items-center justify-center gap-3">
<ul className="flex items-center gap-1.5">
{images.map((image, index) => (
<li key={image.mediaId}>
<button
type="button"
onClick={() => scrollTo(index)}
aria-label={`${index + 1}번째 사진으로`}
aria-current={index === selected}
className={`h-1.5 rounded-full transition-all ${
index === selected ? 'w-5 bg-black/70' : 'w-1.5 bg-black/20 hover:bg-black/40'
}`}
/>
</li>
))}
</ul>
<span className="text-xs tabular-nums opacity-50">
{selected + 1} / {images.length}
</span>
</div>
</>
)}
</div>
) : variantId === 'photos.masonry' ? (
<ul className="columns-2 gap-2 sm:columns-3 sm:gap-4 [&>li]:mb-2 sm:[&>li]:mb-4">
{images.map((image, index) => (
<li key={image.mediaId} className="break-inside-avoid">
<GalleryImage image={image} onOpen={() => setOpenIndex(index)} className="rounded-xl" free />
<div className="relative">
<ul
ref={railRef}
onScroll={onScroll}
className="flex snap-x snap-mandatory gap-2 overflow-x-auto [scrollbar-width:none] lg:grid lg:snap-none lg:grid-cols-4 lg:gap-3 lg:overflow-visible [&::-webkit-scrollbar]:hidden"
>
{images.map((image, i) => (
<li key={image.mediaId} className="w-full shrink-0 snap-center lg:w-auto">
<button
type="button"
onClick={() => setOpenIndex(i)}
className="border-line tpl-border group relative block aspect-4/3 w-full cursor-pointer overflow-hidden rounded-lg border"
aria-label={`${image.alt} 크게 보기`}
>
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
width={image.width}
height={image.height}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
</button>
</li>
))}
</ul>
) : (
<ul className="grid grid-cols-3 gap-2 sm:gap-4 md:grid-cols-4">
{images.map((image, index) => (
<li key={image.mediaId}>
<GalleryImage image={image} onOpen={() => setOpenIndex(index)} className="aspect-square rounded-xl" />
</li>
))}
</ul>
)}
{images.length > 1 && (
<>
<button
type="button"
aria-label="이전 사진"
aria-hidden={index === 0}
tabIndex={index === 0 ? -1 : 0}
onClick={() => move(-1)}
className={`absolute left-2 top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${
index === 0 ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="다음 사진"
aria-hidden={index >= images.length - 1}
tabIndex={index >= images.length - 1 ? -1 : 0}
onClick={() => move(1)}
className={`absolute right-2 top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${
index >= images.length - 1 ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<ChevronRight className="size-4" aria-hidden="true" />
</button>
<span className="pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/55 px-2.5 py-1 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white lg:hidden">
{index + 1} / {images.length}
</span>
</>
)}
</div>
</div>
{openIndex !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
role="dialog"
aria-modal="true"
aria-label="사진 크게 보기"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 backdrop-blur-md"
onClick={() => setOpenIndex(null)}
>
<button
@ -152,7 +135,6 @@ export function GallerySection() {
>
<X className="size-6" />
</button>
<button
type="button"
onClick={(event) => {
@ -164,7 +146,6 @@ export function GallerySection() {
>
<ChevronLeft className="size-6" />
</button>
<button
type="button"
onClick={(event) => {
@ -176,7 +157,6 @@ export function GallerySection() {
>
<ChevronRight className="size-6" />
</button>
<div
className="flex max-h-[85vh] max-w-4xl items-center justify-center"
onClick={(event) => event.stopPropagation()}
@ -192,43 +172,3 @@ export function GallerySection() {
</section>
);
}
function GalleryImage({image, onOpen, className, free = false}: {
image: ReturnType<typeof galleryImages>[number];
onOpen: () => void;
className: string;
free?: boolean;
}) {
return (
<button
type="button"
onClick={onOpen}
className={`group relative block w-full cursor-pointer overflow-hidden border border-black/8 ${className}`}
aria-label={`${image.alt} 크게 보기`}
>
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
width={image.width}
height={image.height}
className={free ? 'h-auto w-full' : 'size-full object-cover transition-transform duration-500 group-hover:scale-105'}
/>
</button>
);
}
function CarouselButton({label, side, onClick}: {label: string; side: 'left' | 'right'; onClick: () => void}) {
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/55 p-2.5 text-white backdrop-blur transition-colors hover:bg-black/75 ${side === 'left' ? 'left-3' : 'right-3'}`}
>
<Icon className="size-5" />
</button>
);
}

View File

@ -1,78 +1,102 @@
import {ArrowRight, MapPin} from 'lucide-react';
import {ChevronDown, MapPin} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {primaryImage} from '@/seo/meta';
import {unitSpec} from '@/lib/derive';
/**
* 첫 화면.
*
* ★ 가운데 정렬이 아니라 **좌하단**이다. 사진이 주인공인 화면에서 글자를 한가운데 얹으면
* 사진의 피사체를 정확히 가린다 — 실제로 그 이유로 좌하단으로 내렸다(/s/stay).
* ★ 색은 전부 템플릿 토큰이다. 여기에 zinc-950 같은 고정색을 쓰면 레트로(갱지)를 골라도
* 첫 화면만 검게 남는다.
* ★ 버튼을 두 개 세우지 않는다. 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다.
*/
export function HeroSection() {
const payload = useSite();
const {place, narrative} = payload;
const image = primaryImage(payload);
const spec = unitSpec(payload);
const region = place.addressLocality ?? place.addressRegion;
const subline = narrative.tagline ?? narrative.heroSubline;
// id="top" — 상단 로고와 하단 탭의 '홈' 이 여기로 돌아온다.
return (
<section
id="top"
className="relative flex min-h-[420px] w-full items-center justify-center overflow-hidden bg-zinc-950 md:min-h-[580px] md:h-[78vh]"
>
{image && (
<div className="absolute inset-0 z-0">
<img
src={image.url}
alt={image.alt}
// 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다.
fetchPriority="high"
decoding="async"
width={image.width}
height={image.height}
className="size-full object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/60 to-zinc-950/30" />
</div>
)}
<div className="shell relative z-10 flex flex-col items-center py-16 text-center text-white">
{(place.addressLocality ?? place.addressRegion) && (
<p className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs backdrop-blur-md sm:mb-6 sm:text-sm">
<MapPin className="size-3.5" />
<span>{place.addressLocality ?? place.addressRegion}</span>
</p>
<section id="top" className="w-full">
<div
className="relative flex w-full items-end overflow-hidden"
style={{
backgroundColor: 'var(--tpl-inverse, #1c1917)',
color: 'var(--tpl-bg, #ffffff)',
minHeight: 'clamp(24rem, 62vh, 36rem)',
}}
>
{image && (
<div className="absolute inset-0 z-0">
<img
src={image.url}
alt={image.alt}
// 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다.
fetchPriority="high"
decoding="async"
width={image.width}
height={image.height}
className="size-full object-cover object-center"
/>
{/* 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */}
<div
className="absolute inset-0"
style={{
background:
'linear-gradient(to top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' +
' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%, transparent 66%)',
}}
/>
</div>
)}
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
<h1 className="serif mb-4 text-2xl font-medium leading-tight tracking-tight sm:text-4xl md:text-5xl lg:text-6xl">
{narrative.heroHeadline ? (
<>
<span className="block">{narrative.heroHeadline}</span>
<span className="mt-1 block font-semibold">{place.name}</span>
</>
) : (
place.name
<div className="shell relative z-10 pb-10 pt-24 sm:pb-12">
{region && (
<p className="mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80">
<MapPin className="size-3.5" aria-hidden="true" />
<span>{region}</span>
</p>
)}
</h1>
{(narrative.tagline ?? narrative.heroSubline) && (
<p className="mb-8 max-w-2xl text-xs font-light leading-relaxed text-white/80 sm:text-base md:text-lg">
{narrative.tagline ?? narrative.heroSubline}
</p>
)}
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
<h1
className="serif"
style={{
fontSize: 'var(--fs-display)',
fontWeight: 'var(--tpl-heading-weight, 700)',
lineHeight: 1.15,
}}
>
{narrative.heroHeadline ? (
<>
<span className="block">{narrative.heroHeadline}</span>
<span className="mt-1 block">{place.name}</span>
</>
) : (
place.name
)}
</h1>
{subline && (
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85">
{subline}
</p>
)}
<div className="flex w-full flex-col items-center gap-3 sm:w-auto sm:flex-row">
{payload.units.length > 0 && (
<a
href="#units"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-xs font-medium text-zinc-950 transition-all hover:bg-white/90 sm:w-auto sm:text-sm"
className="mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] font-medium underline-offset-4 opacity-80 transition-opacity hover:opacity-100"
>
<span>{spec.label} 둘러보기</span>
<ArrowRight className="size-4" />
<span>{spec.label} 보기</span>
<ChevronDown className="size-4" aria-hidden="true" />
</a>
)}
<a
href="#info"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-white/30 bg-white/15 px-6 py-3 text-xs font-medium backdrop-blur-md transition-all hover:bg-white/25 sm:w-auto sm:text-sm"
>
<span>이용 정보 보기</span>
</a>
</div>
</div>
</section>

View File

@ -26,7 +26,7 @@ export function InquirySection() {
<section
id="inquiry"
aria-labelledby="inquiry-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="paper border-line w-full border-b"
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -34,8 +34,9 @@ export function InquirySection() {
<span>Contact</span>
</p>
<h2 id="inquiry-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'inquiry', '문의 안내')}
<h2 id="inquiry-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, 'inquiry', '문의 안내')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
궁금한 점은 아래로 연락 주시면 {place.name}에서 직접 안내해 드립니다.
@ -43,7 +44,7 @@ export function InquirySection() {
<ul
className="max-w-3xl divide-y divide-black/5 overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface-alt)'}}
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
{place.phone && (
<li>

View File

@ -1,145 +1,217 @@
import {ArrowUpRight, Calendar, MapPin, Utensils} from 'lucide-react';
import {useState} from 'react';
import {ArrowUpRight, MapPin, Utensils} from 'lucide-react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {formatKoreanDate, naverSearchUrl} from '@/lib/format';
import {sectionName} from '@/lib/derive';
/**
* 주변 정보.
* 주변 안내.
*
* "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다.
* ★ 마크업은 목업(/s/stay 의 `<section id="guide">`)에서 그대로 옮겼다.
* ★ "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다.
* ★ 우리가 남의 가게 URL 을 지어내지 않는다 — 검색 링크로만 보낸다.
* 틀린 링크는 사용자를 엉뚱한 데로 보내고, 그 책임은 이 홈페이지가 진다.
* ★ 거리 탭은 **좌표가 있을 때만** 뜬다. 거리 없이 "걸어서 5분"을 만들면 손님이 헛걸음한다.
*/
/** 분속 80m 로 환산한 도보 시간. 직선거리 기준이라 실제 길은 이보다 길다. */
const WALK_M_PER_MIN = 80;
function walkMinutes(place: LocalPlace): number | null {
return typeof place.distanceMeters === 'number'
? Math.max(1, Math.round(place.distanceMeters / WALK_M_PER_MIN))
: null;
}
function distanceLabel(place: LocalPlace): string | null {
const meters = place.distanceMeters;
if (typeof meters !== 'number') return place.distanceText ?? null;
return meters >= 1000 ? `${(meters / 1000).toFixed(1)}km` : `${Math.round(meters)}m`;
}
const BANDS = [
{key: 'all', label: '전체', test: () => true},
{key: 'w5', label: '걸어서 5분 이내', test: (m: number | null) => m !== null && m <= 5},
{key: 'w10', label: '걸어서 10분 이내', test: (m: number | null) => m !== null && m <= 10},
{key: 'far', label: '걸어서 10분 이상', test: (m: number | null) => m !== null && m > 10},
] as const;
function PlaceRail({items, label, on}: {items: LocalPlace[]; label: string; on: (p: LocalPlace) => boolean}) {
if (items.length === 0) return null;
return (
<div className="relative">
<div
className="flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="group"
aria-roledescription="캐러셀"
aria-label={label}
tabIndex={0}
>
{items.map((place) => {
const minutes = walkMinutes(place);
const distance = distanceLabel(place);
return (
<div
key={place.name}
hidden={!on(place)}
className="min-w-0 shrink-0 grow-0 basis-[76%] snap-start sm:basis-1/3 lg:basis-1/4"
aria-roledescription="슬라이드"
>
<a
href={naverSearchUrl(place.searchQuery)}
target="_blank"
rel="noopener noreferrer nofollow"
className="panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85"
>
{place.imageUrl && (
<span className="relative block aspect-4/3 overflow-hidden">
<img
src={place.imageUrl}
alt={`${place.name} 사진`}
loading="lazy"
decoding="async"
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
{minutes !== null && (
<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)',
}}
>
도보 약 {minutes}분
{distance && <span className="ml-1 font-normal opacity-70">{distance}</span>}
</span>
)}
</span>
)}
<span className="flex flex-1 flex-col gap-1 p-3.5">
<span className="text-[length:var(--fs-sm)] font-bold">{place.name}</span>
{place.description && (
<span className="text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed">
{place.description}
</span>
)}
<span className="text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100">
<span>검색으로 열기</span>
<ArrowUpRight className="size-3" aria-hidden="true" />
</span>
</span>
</a>
</div>
);
})}
</div>
</div>
);
}
export function LocalGuideSection() {
const payload = useSite();
const {local} = payload;
const hasAnything =
local.restaurants.length > 0 || local.attractions.length > 0 || local.festivals.length > 0;
const [band, setBand] = useState<string>('all');
const hasAnything = local.restaurants.length > 0 || local.attractions.length > 0;
if (!hasAnything) return null;
const all = [...local.restaurants, ...local.attractions];
const hasDistance = all.some((place) => typeof place.distanceMeters === 'number');
const active = BANDS.find((b) => b.key === band) ?? BANDS[0];
// ★ 거르지 않고 **접는다**(hidden). 걸러 버리면 접힌 가게가 HTML 에서 사라져
// 검색·AI 가 못 읽는다 — 이 섹션을 둔 이유가 지역 고유명사를 본문에 두는 것이다.
const isOn = (place: LocalPlace) => band === 'all' || active.test(walkMinutes(place));
const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
const groups = [
{title: '주변 맛집', Icon: Utensils, items: local.restaurants},
{title: '주변 명소', Icon: MapPin, items: local.attractions},
].filter((group) => group.items.length > 0);
return (
<section
id="guide"
aria-labelledby="guide-heading"
className="w-full border-b border-black/8 py-12 sm:py-20"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell max-w-[880px]">
<div className="mb-8 flex flex-col justify-between gap-2 border-b border-black/8 pb-4 sm:flex-row sm:items-baseline">
<div>
<h2 id="guide-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl">
{'주변 안내'}
</h2>
<p className="mt-1 text-xs opacity-60 sm:text-sm">
{payload.place.addressLocality ?? '주변'} 지역의 맛집 · 명소 · 행사 안내입니다.
</p>
</div>
{local.syncedAt && (
<p className="self-start text-[11px] opacity-50 sm:self-auto">
{formatKoreanDate(local.syncedAt)} 갱신
</p>
)}
<div className="shell">
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="guide-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, 'local', '주변 안내')}</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
{region} 지역의 맛집 · 명소 안내입니다.
</p>
</div>
{local.syncedAt && (
<div className="shrink-0">
<p className="text-muted text-[length:var(--fs-xs)]">
{formatKoreanDate(local.syncedAt)} 갱신
</p>
</div>
)}
</div>
</header>
</div>
{local.restaurants.length > 0 && (
<PlaceList title="주변 맛집" icon={Utensils} places={local.restaurants} />
)}
{local.attractions.length > 0 && (
<PlaceList title="주변 명소" icon={MapPin} places={local.attractions} />
)}
{local.festivals.length > 0 && (
<div className="space-y-4">
<h3 className="serif flex items-center gap-2 text-base font-bold sm:text-lg">
<Calendar className="size-4 opacity-50" />
<span>지역 행사</span>
</h3>
<ul
className="divide-y divide-black/5 overflow-hidden rounded-xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
>
{local.festivals.map((festival) => (
<li key={festival.name} className="flex items-center justify-between gap-3 p-4">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<span className="rounded bg-black/5 px-1.5 py-0.5 text-[11px] font-bold">
{festival.month}
</span>
<h4 className="truncate text-xs font-bold sm:text-sm">{festival.name}</h4>
</div>
<p className="text-xs opacity-60">
{festival.description ?? festival.period}
</p>
</div>
<a
href={festival.officialUrl ?? naverSearchUrl(festival.searchQuery)}
target="_blank"
rel="noopener noreferrer nofollow"
className="shrink-0 rounded-lg border border-black/10 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-black/5"
>
자세히
</a>
</li>
))}
</ul>
{hasDistance && (
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="거리">
{BANDS.map((b) => {
const count =
b.key === 'all' ? all.length : all.filter((p) => b.test(walkMinutes(p))).length;
if (count === 0) return null;
const on = b.key === band;
return (
<button
key={b.key}
type="button"
role="tab"
aria-selected={on}
onClick={() => setBand(b.key)}
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
style={
on
? {
backgroundColor: 'var(--color-brand)',
color: 'var(--tpl-bg, #fff)',
borderColor: 'transparent',
}
: undefined
}
>
{b.label} <span className="tabular-nums opacity-70">{count}</span>
</button>
);
})}
</div>
)}
<div className="space-y-10">
{groups.map((group) => (
<div key={group.title}>
<h3 className="border-line mb-4 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
<group.Icon className="size-4" aria-hidden="true" />
<span>{group.title}</span>
</h3>
<PlaceRail items={group.items} label={group.title} on={isOn} />
</div>
))}
</div>
{hasDistance && (
<p className="text-muted measure mt-5 text-[length:var(--fs-xs)]">
도보 시간은 숙소에서 잰 직선거리를 분속 {WALK_M_PER_MIN}m 로 환산한 값입니다. 실제로 걷는
길은 이보다 깁니다.
</p>
)}
</div>
</section>
);
}
function PlaceList({
title,
icon: Icon,
places,
}: {
title: string;
icon: typeof MapPin;
places: LocalPlace[];
}) {
return (
<div className="mb-8 space-y-4">
<h3 className="serif flex items-center gap-2 text-base font-bold sm:text-lg">
<Icon className="size-4 opacity-50" />
<span>{title}</span>
</h3>
<ul
className="divide-y divide-black/5 overflow-hidden rounded-xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
>
{places.map((place) => (
<li key={place.name}>
<a
href={naverSearchUrl(place.searchQuery)}
target="_blank"
rel="noopener noreferrer nofollow"
className="group flex items-center justify-between gap-3 p-4 transition-colors hover:bg-black/[0.02]"
>
<div className="min-w-0 space-y-0.5 pr-3">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-bold sm:text-sm">{place.name}</span>
{(place.distanceText ?? place.durationText) && (
<span className="shrink-0 font-mono text-[10px] opacity-50">
{place.distanceText ?? place.durationText}
</span>
)}
</div>
{place.description && (
<p className="truncate text-xs opacity-60">{place.description}</p>
)}
</div>
<span className="flex shrink-0 items-center gap-0.5 text-xs opacity-40 transition-opacity group-hover:opacity-80">
<span>검색</span>
<ArrowUpRight className="size-3.5" />
</span>
</a>
</li>
))}
</ul>
</div>
);
}

View File

@ -1,8 +1,18 @@
import {useState} from 'react';
import {Car, Check, Copy, MapPin, Navigation} from 'lucide-react';
import {Car, Check, Copy, Navigation} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl} from '@/lib/format';
import {sectionName} from '@/lib/derive';
/**
* 오시는 길.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="location">`)에서 그대로 옮겼다.
* 지도 7 : 주소·길찾기 5 의 가로 배치다. 지도를 위에 눕히면 주소가 접힌 화면 밖으로 밀린다.
* ★ 길찾기 버튼 색은 각 서비스의 브랜드색을 그대로 쓴다(네이버 #03C75A · 카카오 #FEE500 ·
* 티맵 #0064FF). 템플릿 색으로 칠하면 어느 앱으로 가는지 손님이 못 알아본다.
* ★ 티맵은 앱 딥링크라 손가락 화면에서만 보인다(`only-touch`).
*/
export function LocationSection() {
const payload = useSite();
const {place, routes} = payload;
@ -15,6 +25,7 @@ export function LocationSection() {
// 버튼은 검색으로 떨어진다(format.ts) — 틀린 핀을 찍느니 안 찍는다.
const {latitude: lat, longitude: lng} = place;
const hasGeo = lat != null && lng != null;
const q = encodeURIComponent(place.name);
const handleCopy = async () => {
try {
@ -30,136 +41,120 @@ export function LocationSection() {
<section
id="location"
aria-labelledby="location-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<MapPin className="size-4" />
<span>Location</span>
</p>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="location-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, 'map', '오시는 길')}</span>
</h2>
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
주소와 주요 거점까지의 이동 시간을 안내합니다.
</p>
</div>
</div>
</header>
</div>
<h2 id="location-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{'오시는 길'}
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
주소와 주요 거점까지의 이동 시간을 안내합니다.
</p>
<div className="grid gap-6 lg:grid-cols-12 lg:gap-8">
{hasGeo && (
<div className="border-line tpl-border overflow-hidden rounded-xl border lg:col-span-7">
{/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를
남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */}
<iframe
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="block aspect-4/3 w-full border-0 sm:aspect-video lg:aspect-auto lg:h-full lg:min-h-[24rem]"
/>
</div>
)}
{hasGeo && (
<div className="mb-6 overflow-hidden rounded-2xl border border-black/8">
{/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를
남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */}
<iframe
title={`${place.name} 위치 지도`}
src={osmEmbedUrl(lat, lng)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="block aspect-[16/9] w-full border-0"
/>
</div>
)}
<div
className="mb-8 rounded-2xl border border-black/8 p-5 sm:p-6"
style={{backgroundColor: 'var(--color-surface-alt)'}}
>
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<p className="text-[11px] font-bold uppercase tracking-wide opacity-50">주소</p>
<div className={hasGeo ? 'lg:col-span-5' : 'lg:col-span-12'}>
<div className="panel p-5 sm:p-6">
<p className="label mb-2">주소</p>
<div className="flex flex-wrap items-center gap-2">
{/* address 태그로 감싼다 — 기계가 "이게 이 업소의 주소"임을 안다. */}
<address className="text-base font-bold not-italic sm:text-lg">{address}</address>
<address className="text-[length:var(--fs-lead)] font-bold not-italic">{address}</address>
<button
type="button"
onClick={handleCopy}
title="주소 복사"
className="inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5"
style={{backgroundColor: 'var(--color-surface)'}}
className="border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/8 px-2.5 py-1.5 text-[length:var(--fs-xs)] font-medium transition-opacity hover:opacity-70"
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
<span>{copied ? '복사완료' : '복사'}</span>
</button>
</div>
{place.phone && (
<p className="text-xs opacity-60">
문의: <a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">{place.phone}</a>
<p className="text-muted mt-2 text-[length:var(--fs-sm)]">
문의:{' '}
<a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">
{place.phone}
</a>
</p>
)}
</div>
<div className="flex items-center gap-2">
<a
href={naverDirectionsUrl(place.name, lat, lng)}
target="_blank"
rel="noopener noreferrer nofollow"
className="flex items-center gap-1.5 rounded-xl bg-emerald-600 px-4 py-2.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
>
<Navigation className="size-3.5" />
<span>네이버 길찾기</span>
</a>
<a
href={kakaoDirectionsUrl(place.name, lat, lng)}
target="_blank"
rel="noopener noreferrer nofollow"
className="flex items-center gap-1.5 rounded-xl bg-amber-400 px-4 py-2.5 text-xs font-semibold text-zinc-950 transition-opacity hover:opacity-90"
>
<Navigation className="size-3.5" />
<span>카카오 길찾기</span>
</a>
</div>
</div>
</div>
{routes.length > 0 && (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-bold sm:text-base">
<Car className="size-4 opacity-60" />
<span>주요 거점 소요시간</span>
</h3>
<div
className="overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface)'}}
>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs sm:text-sm">
<caption className="sr-only">
{payload.place.name}에서 주요 거점까지의 이동 시간
</caption>
<thead
className="border-b border-black/8 font-semibold"
style={{backgroundColor: 'var(--color-surface-alt)'}}
<div className="mt-5 flex flex-wrap gap-2.5">
<a
href={naverDirectionsUrl(place.name, lat, lng)}
target="_blank"
rel="noopener noreferrer nofollow"
className="tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#03C75A] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90"
>
<Navigation className="size-3.5" aria-hidden="true" />
<span>네이버 길찾기</span>
</a>
<a
href={kakaoDirectionsUrl(place.name, lat, lng)}
target="_blank"
rel="noopener noreferrer nofollow"
className="tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#FEE500] px-4 text-[length:var(--fs-sm)] font-bold text-[#191600] transition-opacity hover:opacity-90"
>
<Navigation className="size-3.5" aria-hidden="true" />
<span>카카오 길찾기</span>
</a>
{hasGeo && (
<a
href={`tmap://route?goalname=${q}&goalx=${lng}&goaly=${lat}&rGoName=${q}&rGoX=${lng}&rGoY=${lat}`}
className="tap only-touch flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#0064FF] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90"
>
<tr>
<th scope="col" className="p-3.5 sm:p-4">목적지</th>
<th scope="col" className="p-3.5 sm:p-4">차량</th>
<th scope="col" className="p-3.5 sm:p-4">도보 / 대중교통</th>
<th scope="col" className="hidden p-3.5 sm:table-cell sm:p-4">거리</th>
<th scope="col" className="hidden p-3.5 md:table-cell sm:p-4">비고</th>
</tr>
</thead>
<tbody className="divide-y divide-black/5">
{routes.map((route) => (
<tr key={route.destination}>
<th scope="row" className="p-3.5 text-left font-bold sm:p-4">
{route.destination}
</th>
<td className="p-3.5 font-semibold sm:p-4">{route.byCar ?? '—'}</td>
<td className="p-3.5 opacity-70 sm:p-4">{route.byTransit ?? '—'}</td>
<td className="hidden p-3.5 font-mono opacity-50 sm:table-cell sm:p-4">
{route.distanceText ?? '—'}
</td>
<td className="hidden p-3.5 text-xs opacity-50 md:table-cell sm:p-4">
{route.note ?? ''}
</td>
</tr>
))}
</tbody>
</table>
<Navigation className="size-3.5" aria-hidden="true" />
<span>티맵 길찾기</span>
</a>
)}
</div>
</div>
{routes.length > 0 && (
<div className="mt-6">
<h3 className="mb-3 flex items-center gap-2 text-[length:var(--fs-sm)] font-bold">
<Car className="size-4 opacity-60" aria-hidden="true" />
<span>주요 거점 소요시간</span>
</h3>
<dl className="divide-line border-line divide-y border-t">
{routes.map((route) => (
<div
key={route.destination}
className="flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]"
>
<dt className="text-muted shrink-0">{route.destination}</dt>
<dd className="text-right font-semibold">
{[route.byCar, route.byTransit].filter(Boolean).join(' · ') || '—'}
</dd>
</div>
))}
</dl>
</div>
)}
</div>
)}
</div>
</div>
</section>
);

View File

@ -26,7 +26,7 @@ export function RulesSection() {
<section
id="rules"
aria-labelledby="rules-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="paper border-line w-full border-b"
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -34,8 +34,9 @@ export function RulesSection() {
<span>House Rules</span>
</p>
<h2 id="rules-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'rules', '이용 규정')}
<h2 id="rules-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, 'rules', '이용 규정')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
예약 전에 꼭 확인해 주세요. 아래 내용은 모두 사업자가 확인한 규정입니다.
@ -47,7 +48,7 @@ export function RulesSection() {
<div
key={row.label}
className="rounded-2xl border border-black/8 p-4 sm:p-5"
style={{backgroundColor: 'var(--color-surface-alt)'}}
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
<dt className="mb-1 text-xs font-bold sm:text-sm">{row.label}</dt>
<dd className="text-xs leading-relaxed opacity-80 sm:text-sm">{row.value}</dd>

View File

@ -0,0 +1,68 @@
/**
* 섹션 한 벌 — 바탕·여백·머리말.
*
* ★ 왜 공통으로 빼나
* 섹션마다 `py-24`, `text-2xl`, `font-bold` 를 손으로 적고 있었다. 그래서 템플릿이
* `sectionSpace: 4rem` 이나 `headingWeight: 400`(간판체)을 줘도 화면은 그대로였다 —
* 사장님이 레트로를 골라도 제목만 가짜 볼드로 굵어졌고, 여백은 언제나 96px 이었다.
* 여기 한 곳에서 토큰을 읽으면 템플릿이 실제로 화면을 바꾼다.
*
* ★ 제목 앞 세로 막대는 장식이 아니라 위계다. 같은 크기의 글자가 페이지에 여럿일 때
* "여기서 새 섹션이 시작한다"를 알려주는 유일한 표시다.
*/
import type {ReactNode} from 'react';
export function SectionHeading({id, name, subtitle, bar = true, right}: {
id: string;
name: string;
subtitle?: string;
/** 제목 앞 세로 막대. 아이템 섹션처럼 자체 머리말이 있는 곳은 끈다. */
bar?: boolean;
/** 제목 오른쪽에 붙는 것(갱신일·바깥 링크). */
right?: ReactNode;
}) {
return (
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id={`${id}-heading`} className={bar ? 'h2 flex items-center gap-2.5' : 'h2'}>
{bar && <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />}
<span className="min-w-0">{name}</span>
</h2>
{subtitle && (
<p
className={`text-muted measure mt-2 text-[length:var(--fs-sm)]${bar ? ' pl-[calc(3px+0.625rem)]' : ''}`}
>
{subtitle}
</p>
)}
</div>
{right}
</div>
</header>
);
}
export function SectionShell({id, children, tone = 'base', border = true, className}: {
id: string;
children: ReactNode;
/** base = 페이지 바탕, alt = 한 단계 다른 면(섹션이 이어질 때 경계를 만든다). */
tone?: 'base' | 'alt';
border?: boolean;
className?: string;
}) {
return (
<section
id={id}
aria-labelledby={`${id}-heading`}
className={`paper w-full${border ? ' border-line border-b' : ''}${className ? ` ${className}` : ''}`}
style={{
backgroundColor: tone === 'alt' ? 'var(--tpl-surface-alt, #fafafa)' : 'var(--tpl-surface, #ffffff)',
color: 'var(--tpl-text, #09090b)',
paddingBlock: 'var(--section-space)',
}}
>
<div className="shell">{children}</div>
</section>
);
}

View File

@ -28,7 +28,7 @@ export function SpaceSection() {
<section
id="space"
aria-labelledby="space-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="paper border-line w-full border-b"
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -36,8 +36,9 @@ export function SpaceSection() {
<span>Space</span>
</p>
<h2 id="space-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, 'space', '공간 안내')}
<h2 id="space-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, 'space', '공간 안내')}</span>
</h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
좌석과 이용 환경 안내입니다.
@ -48,7 +49,7 @@ export function SpaceSection() {
<div
key={row.label}
className="rounded-2xl border border-black/8 p-4 sm:p-5"
style={{backgroundColor: 'var(--color-surface-alt)'}}
style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
>
<dt className="mb-1 text-xs font-medium opacity-60">{row.label}</dt>
<dd className="text-sm font-bold sm:text-base">{row.value}</dd>

View File

@ -0,0 +1,101 @@
/**
* 지역 이야기 — 아이템 섹션들을 탭 하나로 묶는 머리.
*
* ★ 왜 묶나 — 가요·인물·연표·엽서·퀴즈가 각자 큰 섹션으로 서면 페이지가 다섯 배로 길어지고,
* 손님은 객실 정보에 닿기 전에 스크롤을 포기한다. 이야기는 '읽고 싶은 사람만' 여는 자리다.
* ★ **탭을 눌러 접는 것은 화면뿐이다.** 각 아이템 섹션은 그대로 HTML 에 다 구워진다 —
* 이 사이트의 존재 이유가 인용이라, 접힌 쪽이 HTML 에 없으면 만든 의미가 없다.
*/
import {useState} from 'react';
import type {ReactElement} from 'react';
import {useSite} from '@/lib/site-context';
import {isSectionEnabled, sectionItems, sectionName} from '@/lib/derive';
import {ITEM_SECTIONS} from './items';
/** 이야기 탭에 들어가는 아이템. 순서가 곧 탭 순서다. */
const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily'] as const;
const FALLBACK_LABEL: Record<string, string> = {
songs: '가요 다방',
people: '인물 열전',
chronicle: '시간의 골목',
literature: '문학 산책',
postcard: '오늘의 엽서',
quiz: '뒤집어 보는 질문',
daily: '오늘의 일력',
};
export function StorySection(): ReactElement | null {
const payload = useSite();
const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
// 켜져 있고 실제로 항목이 있는 것만 탭이 된다 — 빈 탭을 누르게 하지 않는다.
const tabs = STORY_KINDS.filter(
(kind) => isSectionEnabled(payload, kind) && sectionItems(payload, kind).items.length > 0,
);
const [active, setActive] = useState<string>(tabs[0] ?? '');
if (tabs.length === 0) return null;
return (
<>
<section
id="story"
aria-labelledby="story-heading"
className="paper w-full"
style={{
backgroundColor: 'var(--tpl-surface, #fafafa)',
color: 'var(--tpl-text, #09090b)',
paddingTop: 'var(--section-space)',
paddingBottom: 'calc(var(--section-space) * 0.55)',
}}
>
<div className="shell">
<h2 id="story-heading" className="h2">
{region} 이야기
</h2>
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70">
이 도시를 {tabs.length} 갈래로 봅니다. 하나씩 골라 보세요.
</p>
<div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label={`${region} 이야기`}>
{tabs.map((kind) => {
const on = kind === active;
return (
<button
key={kind}
type="button"
role="tab"
aria-selected={on}
aria-controls={kind}
onClick={() => setActive(kind)}
className="rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
style={
on
? {
backgroundColor: 'var(--tpl-text, #09090b)',
color: 'var(--tpl-bg, #ffffff)',
borderColor: 'var(--tpl-text, #09090b)',
}
: {borderColor: 'var(--tpl-border, #d6d3d1)'}
}
>
{sectionName(payload, kind, FALLBACK_LABEL[kind] ?? kind)}
</button>
);
})}
</div>
</div>
</section>
{tabs.map((kind) => {
const Component = ITEM_SECTIONS[kind];
if (!Component) return null;
return (
<div key={kind} hidden={kind !== active}>
<Component />
</div>
);
})}
</>
);
}

View File

@ -1,18 +1,92 @@
import {ChevronDown, Phone} from 'lucide-react';
import {useRef, useState} from 'react';
import {ChevronDown, ChevronLeft, ChevronRight} from 'lucide-react';
import type {MediaItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionName, unitSpec, unitViews} from '@/lib/derive';
/**
* 객실 · 메뉴 · 프로그램 — 업종에 따라 이름만 달라진다.
*
* ★ 예전에는 카드가 상세 **페이지**로 넘어갔다. 사이트를 한 장으로 합치면서
* 그 페이지의 내용(이용 정보 표 · 사진 전체 · 전화 문의)을 카드 안으로 들여왔다.
* 빼고 합쳤으면 확인된 fact 가 화면에서 사라지고, JSON-LD 에는 남아 있어
* 발행 게이트(화면 ↔ 구조화 데이터 대조)에 걸린다.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="units">`)에서 그대로 옮겼다.
* ★ 사진은 카드 안에서 가로로 민다. 격자로 늘어놓으면 카드가 세로로 길어져
* 객실 두 개가 한 화면에 안 들어온다.
* ★ 펼침은 <details> 다. 자바스크립트 없이 동작하고, 크롤러는 접힌 내용도 읽는다 —
* 조건부 렌더로 감추면 HTML 에 아예 없어서 AI 가 못 본다.
*/
function UnitPhotos({images, label}: {images: MediaItem[]; label: string}) {
const railRef = useRef<HTMLUListElement>(null);
const [index, setIndex] = useState(0);
// ★ 스크롤 위치로 현재 장을 읽는다. 상태로만 밀면 손가락으로 민 것과 어긋난다.
const onScroll = () => {
const rail = railRef.current;
if (!rail) return;
setIndex(Math.round(rail.scrollLeft / Math.max(1, rail.clientWidth)));
};
const move = (delta: number) => {
const rail = railRef.current;
if (!rail) return;
rail.scrollTo({left: (index + delta) * rail.clientWidth, behavior: 'smooth'});
};
return (
<div className="relative">
<ul
ref={railRef}
onScroll={onScroll}
aria-label={`${label} 사진`}
className="flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{images.map((image) => (
<li key={image.mediaId} className="w-full shrink-0 snap-center">
<div className="relative aspect-16/10 overflow-hidden">
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
</div>
</li>
))}
</ul>
{images.length > 1 && (
<>
<button
type="button"
aria-label="이전 사진"
aria-hidden={index === 0}
tabIndex={index === 0 ? -1 : 0}
onClick={() => move(-1)}
className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 left-2 ${
index === 0 ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-label="다음 사진"
aria-hidden={index >= images.length - 1}
tabIndex={index >= images.length - 1 ? -1 : 0}
onClick={() => move(1)}
className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 right-2 ${
index >= images.length - 1 ? 'pointer-events-none opacity-0' : 'opacity-100'
}`}
>
<ChevronRight className="size-4" aria-hidden="true" />
</button>
<span className="pointer-events-none absolute bottom-2.5 right-2.5 rounded-full bg-black/55 px-2 py-0.5 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white">
{index + 1} / {images.length}
</span>
</>
)}
</div>
);
}
export function UnitsSection() {
const payload = useSite();
const spec = unitSpec(payload);
@ -24,130 +98,75 @@ export function UnitsSection() {
<section
id="units"
aria-labelledby="units-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<div className="mb-10 flex flex-col justify-between gap-4 sm:flex-row sm:items-end">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider opacity-50">
{spec.label}
</p>
{/* 섹션 id 는 업종마다 다르다(rooms · menu · programs) — spec.path 가 그 id 다.
사장님이 이름을 안 바꿨으면 지금까지 쓰던 "객실 3개 안내" 꼴로 떨어진다. */}
<h2 id="units-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl">
{sectionName(payload, spec.path, `${spec.label} ${units.length}개 안내`)}
</h2>
</div>
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="units-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, spec.path, `${spec.label} 안내`)}</span>
</h2>
</div>
</div>
</header>
</div>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:gap-8">
{/* ★ items-start — 사진 없는 객실 카드가 옆 카드 높이를 따라가면 아래가 빈 칸으로 남는다.
목업은 전 객실에 사진이 있어 이 문제가 없었지만, 공공 API 사진은 일부 객실에만 붙는다. */}
<ul className="grid grid-cols-1 items-start gap-5 sm:grid-cols-2 lg:gap-7">
{units.map((unit) => (
<li
key={unit.unitId}
className="flex flex-col justify-between overflow-hidden rounded-2xl border border-black/8"
style={{backgroundColor: 'var(--color-surface-alt)'}}
>
<div>
{unit.images[0] && (
<div className="relative aspect-16/10 overflow-hidden sm:aspect-16/9">
<img
src={unit.images[0].url}
alt={unit.images[0].alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
{unit.priceText && (
<span
className="absolute bottom-3 right-3 rounded-lg px-2.5 py-1 text-xs font-bold"
style={{
backgroundColor: 'var(--color-surface)',
color: 'var(--color-ink)',
}}
>
{unit.priceText}
</span>
)}
</div>
)}
<li key={unit.unitId} className="panel flex flex-col overflow-hidden">
<div className="flex flex-1 flex-col">
{unit.images.length > 0 && <UnitPhotos images={unit.images} label={unit.name} />}
<div className="space-y-3 p-4 sm:space-y-4 sm:p-6">
<h3 className="serif text-base font-bold leading-snug sm:text-xl">
{unit.name}
</h3>
<div className="flex flex-1 flex-col gap-3 p-5">
<h3 className="h3">{unit.name}</h3>
{unit.chips.length > 0 && (
<ul className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<ul className="flex flex-wrap items-center gap-1.5">
{unit.chips.map((chip) => (
<li
key={chip.label}
className="rounded-lg border border-black/8 px-2.5 py-1 text-xs"
style={{backgroundColor: 'var(--color-surface)'}}
>
<span className="opacity-50">{chip.label} </span>
<span className="font-medium">{chip.value}</span>
<li key={chip.label}>
<span className="border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/5 px-2.5 py-1 text-[length:var(--fs-xs)]">
<span className="text-muted">{chip.label}</span>
<span className="font-medium">{chip.value}</span>
</span>
</li>
))}
</ul>
)}
{unit.intro && (
<p className="text-xs leading-relaxed opacity-70">{unit.intro}</p>
<p className="text-[length:var(--fs-sm)] leading-relaxed opacity-75">{unit.intro}</p>
)}
</div>
</div>
{(unit.rows.length > 0 || unit.images.length > 1) && (
<details className="group border-t border-black/8">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 p-4 text-xs font-semibold sm:p-6">
{unit.rows.length > 0 && (
<details className="group border-line border-t">
<summary className="tap flex cursor-pointer list-none items-center justify-between gap-2 px-5 text-[length:var(--fs-sm)] font-semibold">
<span>{unit.name} 자세히</span>
<ChevronDown className="size-4 transition-transform group-open:rotate-180" />
<ChevronDown
className="size-4 transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</summary>
<div className="space-y-5 px-4 pb-4 sm:px-6 sm:pb-6">
{unit.rows.length > 0 && (
<dl className="divide-y divide-black/5 border-t border-black/8">
{unit.rows.map((row) => (
<div
key={row.label}
className="flex items-start justify-between gap-4 py-2 text-xs"
>
<dt className="shrink-0 opacity-55">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
{unit.images.length > 1 && (
<ul className="grid grid-cols-2 gap-2">
{unit.images.slice(1).map((image) => (
<li
key={image.mediaId}
className="relative aspect-16/10 overflow-hidden rounded-xl"
>
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
</li>
))}
</ul>
)}
{payload.place.phone && (
<a
href={`tel:${payload.place.phone}`}
className="flex w-full items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-xs font-semibold text-white transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)'}}
>
<Phone className="size-3.5" />
<span>{payload.place.phone} 문의</span>
</a>
)}
<div className="space-y-5 px-5 pb-5">
<dl className="divide-line border-line divide-y border-t">
{unit.rows.map((row) => (
<div
key={row.label}
className="flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]"
>
<dt className="text-muted shrink-0">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div>
))}
</dl>
</div>
</details>
)}

View File

@ -1,8 +1,25 @@
import {Cloud} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {useLiveWeather} from '@/lib/use-live-weather';
import {sectionName} from '@/lib/derive';
/**
* 오늘의 날씨 — 프리렌더 스냅샷으로 시작해 하이드레이션 후 최신값으로 갱신된다.
*
* ★ 마크업은 목업(/s/stay 의 `<section id="weather">`)에서 그대로 옮겼다.
* ★ 기온은 히어로 제목과 같은 크기(`--fs-display`)로 크게 둔다. 이 섹션에서 손님이
* 찾는 값은 숫자 하나뿐이라, 그 숫자가 작으면 섹션을 둔 의미가 없다.
* ★ 정적 페이지는 몇 달 산다 — 관측 시각을 반드시 함께 적는다. 시각 없는 기온은
* 언제 잰 것인지 알 수 없어 그 자체가 틀린 정보가 된다.
*/
function observedLabel(iso?: string): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return `${d.getMonth() + 1}월 ${d.getDate()}일 ${String(d.getHours()).padStart(2, '0')}:${String(
d.getMinutes(),
).padStart(2, '0')} 관측`;
}
/** 프리렌더 스냅샷으로 시작해 하이드레이션 후 최신 캐시로 갱신되는 독립 날씨 섹션. */
export function WeatherSection() {
const payload = useSite();
const weather = useLiveWeather({
@ -13,33 +30,54 @@ export function WeatherSection() {
});
if (!weather) return null;
const observed = observedLabel(weather.observedAt);
return (
<section
id="weather"
aria-labelledby="weather-heading"
className="w-full border-b border-black/8 py-10 sm:py-14"
style={{backgroundColor: 'var(--color-surface-alt)'}}
className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell max-w-[880px]">
<h2 id="weather-heading" className="serif mb-4 text-xl font-bold tracking-tight sm:text-2xl">
오늘의 날씨
</h2>
<div
className="rounded-xl border border-black/8 p-5 sm:p-6"
style={{backgroundColor: 'var(--color-surface)'}}
>
<div className="flex items-center gap-3">
<Cloud className="size-6 opacity-50" aria-hidden="true" />
<div className="flex items-baseline gap-2">
<span className="text-2xl font-extrabold sm:text-3xl">{weather.temperature}°C</span>
<span className="text-xs font-semibold opacity-70">{weather.condition}</span>
{weather.stale && <span className="text-[10px] text-amber-700">최근 관측값</span>}
<div className="shell">
<div>
<header className="mb-6 sm:mb-8">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="weather-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, 'weather', '오늘의 날씨')}</span>
</h2>
</div>
</div>
</header>
</div>
<div className="panel relative overflow-hidden">
{/* 하늘 — 날씨에 따라 결이 달라진다. 색을 박지 않고 토큰에서 유도한다. */}
<div className="w4-sky" data-mood={weather.condition} aria-hidden="true" />
<div className="relative flex flex-col gap-5 p-6 sm:p-8">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<p className="flex items-baseline gap-2.5">
<span
className="serif tabular-nums leading-none"
style={{fontSize: 'var(--fs-display)'}}
>
{weather.temperature}
</span>
<span className="text-[length:var(--fs-lead)] opacity-70">°C</span>
<span className="text-[length:var(--fs-lead)] font-semibold">{weather.condition}</span>
</p>
{observed && (
<span className="text-muted text-[length:var(--fs-xs)] tabular-nums">{observed}</span>
)}
</div>
{weather.note && (
<p className="measure serif text-[length:var(--fs-lead)] leading-loose opacity-85">
{weather.note}
</p>
)}
</div>
{weather.note && (
<p className="mt-2 text-xs leading-relaxed opacity-70 sm:text-sm">{weather.note}</p>
)}
</div>
</div>
</section>

View File

@ -10,6 +10,8 @@ export {SpaceSection} from './SpaceSection';
export {InquirySection} from './InquirySection';
export {ExhibitionSection} from './ExhibitionSection';
export {LocalGuideSection} from './LocalGuideSection';
export {FestivalSection} from './FestivalSection';
export {StorySection} from './StorySection';
export {WeatherSection} from './WeatherSection';
export {GallerySection} from './GallerySection';
export {LocationSection} from './LocationSection';

View File

@ -24,7 +24,7 @@ export function ChronicleSection() {
<ItemSection
id="chronicle"
name={parsed.title || sectionName(payload, 'chronicle', '시간의 골목')}
subtitle={parsed.subtitle}
subtitle={parsed.subtitle ?? '이 도시를 만든 해들'}
count={`붉은 점은 도시의 성격이 바뀐 해입니다 · ${turning}개 / 전체 ${items.length}개`}
>
<Rail label="연표">
@ -51,11 +51,23 @@ export function ChronicleSection() {
}}
/>
</div>
<div className="space-y-1.5 pr-5">
<h3 className="serif text-base font-bold">{item.title}</h3>
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
<div className="flex gap-2.5 pr-5">
{item.imageUrl && (
<img
src={item.imageUrl}
alt={`${item.title} 사진`}
loading="lazy"
decoding="async"
className="size-14 shrink-0 border object-cover"
style={{borderColor: ITEM_BORDER}}
/>
)}
<div className="min-w-0 flex-1 space-y-1.5">
<h3 className="serif text-base font-bold">{item.title}</h3>
{item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>}
{item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>}
<SourceLine source={item.source} verified={item.verified} />
</div>
</div>
</article>
);

View File

@ -0,0 +1,54 @@
/**
* 소식 — 사장님이 쓰는 공지·안내.
*
* ★ 지역 리서치 아이템과 성격이 다르다. 여기 글의 주인은 사장님이고 출처도 사장님이다 —
* 그래서 source 를 요구하지 않는다(SourceLine 대신 verified 배지만).
*/
import type {EventItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, tally} from './common';
export function EventSection() {
const payload = useSite();
const parsed = sectionItems<EventItem>(payload, 'event');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="event"
name={parsed.title || sectionName(payload, 'event', '소식')}
subtitle={parsed.subtitle ?? '미리 알아 두시면 좋은 것들'}
count={tally(parsed.items.length, parsed.unverified, '건')}
>
<ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{parsed.items.map((notice, index) => (
<li
key={`${notice.title}-${index}`}
className="w4-paper flex flex-col border-2 p-5"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
>
{notice.kind && (
<span
className="mb-2 self-start border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}}
>
{notice.kind}
</span>
)}
<h3 className="serif text-[length:var(--fs-lead)] font-bold leading-snug">{notice.title}</h3>
{notice.summary && (
<p className="measure mt-2 text-[length:var(--fs-sm)] opacity-80">{notice.summary}</p>
)}
{notice.body && (
// 줄바꿈이 항목 구분이다. 사장님이 쓴 그대로 둔다.
<p className="mt-3 whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-75">
{notice.body}
</p>
)}
</li>
))}
</ul>
</ItemSection>
);
}

View File

@ -0,0 +1,210 @@
/**
* 추천 일정 — 며칠 묵느냐로 갈린다.
*
* ★ 마크업은 목업(/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>
);
}

View File

@ -16,27 +16,45 @@ export function PeopleSection() {
<ItemSection
id="people"
name={parsed.title || sectionName(payload, 'people', '인물 열전')}
subtitle={parsed.subtitle}
subtitle={parsed.subtitle ?? '이 도시가 배출한 이름들'}
count={`${tally(parsed.items.length, parsed.unverified, '명')} · 사진이 없는 인물은 이름 활자로 대신합니다`}
dark
>
<div className="border border-white/10" style={{backgroundColor: ITEM_INVERSE}}>
<div
className="tpl-border border"
style={{
backgroundColor: ITEM_INVERSE,
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}
>
<div className="w4-film-perf h-2.5 opacity-70" />
<div className="px-3 py-4">
<Rail label="인물 목록">
{parsed.items.map((person, index) => (
<article key={`${person.name}-${index}`} className="w-[210px] shrink-0 snap-center">
<span
className="serif grid aspect-3/4 w-full place-items-center border text-5xl"
// 프레임 안쪽은 필름보다 한 단 밝게 — 색을 박지 않고 글자색을 섞어 만든다.
style={{
backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)',
borderColor: 'color-mix(in oklab, currentColor 28%, transparent)',
}}
aria-hidden
>
{person.name.trim().charAt(0)}
</span>
{person.imageUrl ? (
<img
src={person.imageUrl}
alt={`${person.name} 사진`}
loading="lazy"
decoding="async"
className="aspect-3/4 w-full border object-cover"
style={{borderColor: 'color-mix(in oklab, currentColor 28%, transparent)'}}
/>
) : (
<span
className="serif grid aspect-3/4 w-full place-items-center border text-5xl"
// 사진이 없으면 빈 상자 대신 활판 이니셜. 프레임 안쪽은 필름보다 한 단 밝게 —
// 색을 박지 않고 글자색을 섞어 만든다.
style={{
backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)',
borderColor: 'color-mix(in oklab, currentColor 28%, transparent)',
}}
aria-hidden
>
{person.name.trim().charAt(0)}
</span>
)}
<h3 className="serif mt-3 text-base font-bold">
{person.name}
{person.aka && <span className="ml-1.5 text-xs opacity-60">호 {person.aka}</span>}

View File

@ -17,7 +17,7 @@ export function PostcardSection() {
<ItemSection
id="postcard"
name={parsed.title || sectionName(payload, 'postcard', '오늘의 엽서')}
subtitle={parsed.subtitle}
subtitle={parsed.subtitle ?? '그대로 붙여 쓰는 한 문장'}
count={tally(parsed.items.length, parsed.unverified, '장')}
>
<Rail label="엽서 목록">
@ -27,6 +27,16 @@ export function PostcardSection() {
className="w4-paper w-[304px] shrink-0 snap-center border p-4"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
{card.imageUrl && (
<img
src={card.imageUrl}
alt={`${card.place ?? card.postmark ?? ''} 사진`}
loading="lazy"
decoding="async"
className="mb-3.5 block aspect-3/2 w-full border object-cover"
style={{borderColor: ITEM_BORDER}}
/>
)}
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
<div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}>

View File

@ -1,78 +1,151 @@
/**
* 가요 다방 — 발행본.
*
* 캔버스는 턴테이블에 한 장만 얹지만 발행본은 곡마다 판을 하나씩 놓는다.
* 인용될 문장(story·connection)이 전부 HTML 에 있어야 하기 때문이다.
* ★ 마크업은 목업(/s/stay 의 `<section id="songs">`)에서 그대로 옮겼다.
* 턴테이블 하나에 판을 갈아 끼우고, 아래 미니 판으로 곡을 고른다.
* ★ **전 곡을 HTML 에 굽고 화면에서만 접는다**(`hidden`). 접힌 곡이 HTML 에 없으면
* 이 섹션을 둔 이유(인용)가 사라진다.
* ★ 가사는 어디에도 없다 — 스키마에 lyrics 칸 자체가 없다(원문 전재 금지).
* 그 사실을 화면에도 적는다. 손님이 "왜 가사가 없나" 를 묻지 않게.
*/
import {useState} from 'react';
import {Youtube} from 'lucide-react';
import type {SongItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common';
import {ITEM_ACCENT, ITEM_INVERSE, ItemSection, SourceLine, tally} from './common';
/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */
const FALLBACK_LABEL = ITEM_ACCENT;
/** 라벨 색을 안 주는 곡이 대부분이다. 순환시키면 미니 판이 서로 구별된다. */
const LABEL_COLORS = ['#d4551f', '#1d3c74', '#2a6053', '#7a3b6b', '#8a6d1f'];
export function SongsSection() {
const payload = useSite();
const parsed = sectionItems<SongItem>(payload, 'songs');
const [active, setActive] = useState(0);
if (parsed.items.length === 0) return null;
const labelOf = (song: SongItem, index: number) =>
song.labelColor || LABEL_COLORS[index % LABEL_COLORS.length];
return (
<ItemSection
id="songs"
name={parsed.title || sectionName(payload, 'songs', '가요 다방')}
subtitle={parsed.subtitle}
subtitle={parsed.subtitle ?? '이 도시를 노래한 곡들'}
count={tally(parsed.items.length, parsed.unverified, '곡')}
>
<Rail label="곡 목록">
{parsed.items.map((song, index) => (
<article
key={`${song.title}-${index}`}
className="w4-paper w-[280px] shrink-0 snap-center border p-5"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
<div
className="tpl-border grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[240px_minmax(0,1fr)]"
style={{
backgroundColor: ITEM_INVERSE,
color: 'var(--tpl-bg, #ffffff)',
borderColor: 'color-mix(in oklab, currentColor 22%, transparent)',
}}
>
<div className="relative mx-auto size-[220px]">
<div
className="absolute inset-0 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 16%, transparent)'}}
/>
<div
className="w4-disc w4-spin absolute inset-2 rounded-full"
style={{
['--lbl' as string]: labelOf(parsed.items[active], active),
['--w4-vinyl' as string]: ITEM_INVERSE,
}}
>
<div
className="w4-disc mx-auto size-[132px] rounded-full"
style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
['--w4-vinyl' as string]: ITEM_INVERSE,
}}
>
<span
className="block size-full rounded-full"
style={{boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.06)'}}
/>
</div>
<span
className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 30%, transparent)'}}
/>
</div>
{/* 톤암 — 판 위에 얹힌 바늘. 이게 없으면 그냥 검은 원이다. */}
<div aria-hidden="true" className="absolute -right-1 top-3 h-2 w-[110px] origin-right rotate-6">
<span
className="absolute inset-y-[3px] left-0 right-4 rounded-sm"
style={{backgroundColor: 'color-mix(in oklab, currentColor 65%, transparent)'}}
/>
<span
className="absolute -top-2 right-0 size-6 rounded-full"
style={{backgroundColor: 'color-mix(in oklab, currentColor 55%, transparent)'}}
/>
</div>
</div>
<h3 className="serif mt-4 text-lg font-bold">{song.title}</h3>
<p className="mt-1 text-xs opacity-65">
{[
song.artist,
song.year ? String(song.year) : undefined,
song.lyricist || song.composer
? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}`
: undefined,
song.label,
]
.filter(Boolean)
.join(' · ')}
</p>
{song.story && <p className="mt-3 text-sm leading-relaxed opacity-85">{song.story}</p>}
{song.connection && (
<p className="mt-2 text-sm leading-relaxed" style={{color: ITEM_ACCENT}}>
{song.connection}
<div className="min-w-0">
{parsed.items.map((song, index) => (
<div key={`${song.title}-${index}`} hidden={index !== active} className="space-y-3">
<p className="text-[10px] tracking-[0.24em]" style={{color: ITEM_ACCENT}}>
A면 · {index + 1} / {parsed.items.length}
</p>
)}
<p className="mt-3 border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}>
◎ 가사 대신 이야기 — 원문은 싣지 않습니다
</p>
<div className="mt-2">
<h3 className="serif text-[length:var(--fs-h2)] leading-tight">{song.title}</h3>
<p className="text-[length:var(--fs-xs)] opacity-60">
{[
song.artist,
song.year ? String(song.year) : undefined,
song.lyricist || song.composer
? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}`
: undefined,
song.label,
]
.filter(Boolean)
.join(' · ')}
</p>
{song.story && (
<p className="measure text-[length:var(--fs-sm)] leading-relaxed opacity-85">{song.story}</p>
)}
{song.connection && (
<p className="measure text-[length:var(--fs-sm)] leading-relaxed" style={{color: ITEM_ACCENT}}>
{song.connection}
</p>
)}
<a
href={`https://www.youtube.com/results?search_query=${encodeURIComponent(
`${song.title} ${song.artist ?? ''}`.trim(),
)}`}
target="_blank"
rel="noopener noreferrer nofollow"
className="inline-flex items-center gap-1.5 text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline"
style={{color: ITEM_ACCENT}}
>
<Youtube className="size-4" aria-hidden="true" />
<span>유튜브에서 듣기</span>
</a>
<span
className="block w-fit border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60"
style={{borderColor: 'color-mix(in oklab, currentColor 35%, transparent)'}}
>
◎ 가사 대신 이야기 — 원문은 싣지 않습니다
</span>
<SourceLine source={song.source} verified={song.verified} />
</div>
</article>
))}
</Rail>
))}
</div>
</div>
{parsed.items.length > 1 && (
<div className="mt-4 flex flex-wrap justify-center gap-4">
{parsed.items.map((song, index) => (
<button
key={`${song.title}-pick-${index}`}
type="button"
aria-pressed={index === active}
onClick={() => setActive(index)}
className="w-24 text-center"
>
<span
className="w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105"
style={{
['--lbl' as string]: labelOf(song, index),
['--w4-vinyl' as string]: ITEM_INVERSE,
boxShadow: index === active ? `0 0 0 2px ${ITEM_ACCENT}` : undefined,
}}
/>
<span className="mt-2 block truncate text-[10px] leading-tight opacity-70">{song.title}</span>
</button>
))}
</div>
)}
</ItemSection>
);
}

View File

@ -0,0 +1,128 @@
/**
* 일정 지도 — OpenStreetMap 타일을 직접 깔고 그 위에 번호 핀을 찍는다.
*
* ★ 왜 iframe 이 아닌가
* iframe 임베드는 핀을 하나밖에 못 찍고, 순서(1→2→3)를 그릴 수 없다. 일정에서 중요한 건
* "어디"가 아니라 "어떤 순서로 도는가"라 경로가 보여야 한다.
* ★ 타일은 회색으로 눌러 깐다(`grayscale opacity .55`). 지도가 선명하면 핀과 경로가 묻힌다.
* ★ 핀을 누르면 네이버 길찾기로 간다 — 앞 정거장에서 이 정거장까지. 우리가 경로를
* 계산해 그리지 않는다(지어낸 시간을 그릴 수 없다).
*/
const TILE = 256;
/** 경위도 → Web Mercator 픽셀(줌 z 기준). */
function project(lat: number, lng: number, z: number) {
const n = 2 ** z;
const x = ((lng + 180) / 360) * n * TILE;
const s = Math.sin((lat * Math.PI) / 180);
const y = ((0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n) * TILE;
return {x, y};
}
export interface TripPoint {
name: string;
latitude: number;
longitude: number;
searchQuery?: string;
}
export function TripMap({points, width = 320, height = 168}: {
points: TripPoint[];
width?: number;
height?: number;
}) {
if (points.length === 0) return null;
const lats = points.map((p) => p.latitude);
const lngs = points.map((p) => p.longitude);
const center = {lat: (Math.min(...lats) + Math.max(...lats)) / 2, lng: (Math.min(...lngs) + Math.max(...lngs)) / 2};
// 모든 점이 상자 안에 들어오는 가장 큰 줌. 한 점뿐이면 동네가 보이는 15로 둔다.
let zoom = 15;
for (let z = 16; z >= 10; z -= 1) {
const xs = points.map((p) => project(p.latitude, p.longitude, z).x);
const ys = points.map((p) => project(p.latitude, p.longitude, z).y);
const w = Math.max(...xs) - Math.min(...xs);
const h = Math.max(...ys) - Math.min(...ys);
if (w <= width - 56 && h <= height - 56) {
zoom = z;
break;
}
}
const c = project(center.lat, center.lng, zoom);
const originX = c.x - width / 2;
const originY = c.y - height / 2;
// 화면을 덮는 타일만 깐다.
const tiles: {x: number; y: number; left: number; top: number}[] = [];
const tx0 = Math.floor(originX / TILE);
const ty0 = Math.floor(originY / TILE);
const tx1 = Math.floor((originX + width) / TILE);
const ty1 = Math.floor((originY + height) / TILE);
for (let tx = tx0; tx <= tx1; tx += 1) {
for (let ty = ty0; ty <= ty1; ty += 1) {
tiles.push({x: tx, y: ty, left: tx * TILE - originX, top: ty * TILE - originY});
}
}
const naverTo = (from: TripPoint | undefined, to: TripPoint) => {
const dest = `${to.longitude},${to.latitude},${encodeURIComponent(to.name)}`;
const start = from ? `${from.longitude},${from.latitude},${encodeURIComponent(from.name)}` : '-';
return `https://map.naver.com/p/directions/${start}/${dest}/-/transit`;
};
return (
<div
className="relative mt-3.5 overflow-hidden border-y"
style={{
height,
borderColor: 'var(--tpl-border, #d6d3d1)',
// ★ 타일이 로드되기 전에도 지도 자리가 비어 보이지 않게 — OSM 육지 색이다.
backgroundColor: '#e8e5df',
}}
aria-label="일정 지도"
>
{tiles.map((tile) => (
<img
key={`${tile.x}-${tile.y}`}
src={`https://tile.openstreetmap.org/${zoom}/${tile.x}/${tile.y}.png`}
alt=""
width={TILE}
height={TILE}
loading="lazy"
decoding="async"
className="pointer-events-none absolute max-w-none"
style={{left: tile.left, top: tile.top, filter: 'grayscale(1) contrast(0.95) opacity(0.55)'}}
/>
))}
{points.map((point, index) => {
const p = project(point.latitude, point.longitude, zoom);
return (
<a
key={`${point.name}-${index}`}
href={naverTo(points[index - 1], point)}
target="_blank"
rel="noopener noreferrer"
title={`${point.name} — 네이버 지도`}
className="absolute grid h-[22px] min-w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center whitespace-nowrap rounded-full px-1.5 text-[11px] font-bold shadow-sm transition-transform hover:scale-110"
style={{
left: p.x - originX,
top: p.y - originY,
backgroundColor: 'color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)',
color: 'var(--tpl-bg, #ffffff)',
border: '1.5px solid var(--tpl-bg, #ffffff)',
}}
>
{index + 1}
</a>
);
})}
<span className="absolute bottom-0.5 right-1 bg-white/70 px-1 text-[9px] leading-tight text-stone-600">
© OpenStreetMap
</span>
</div>
);
}

View File

@ -0,0 +1,50 @@
/**
* 영상.
*
* ★ 자동 재생하지 않고 iframe 도 심지 않는다 — 외부 스크립트를 끌어오면 첫 화면이 늦어지고
* 추적 쿠키가 따라 들어온다. 썸네일과 링크만 둔다.
*/
import {Play} from 'lucide-react';
import type {VideoItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
import {ITEM_BORDER, ITEM_INVERSE, ITEM_INVERSE_INK, ItemSection, tally} from './common';
export function VideoSection() {
const payload = useSite();
const parsed = sectionItems<VideoItem>(payload, 'video');
if (parsed.items.length === 0) return null;
return (
<ItemSection
id="video"
name={parsed.title || sectionName(payload, 'video', '영상')}
subtitle={parsed.subtitle}
count={tally(parsed.items.length, parsed.unverified, '편')}
>
<ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{parsed.items.map((video, index) => (
<li key={`${video.url}-${index}`}>
<a
href={video.url}
target="_blank"
rel="noopener noreferrer"
className="group block border-2"
style={{borderColor: ITEM_BORDER}}
>
<div
className="flex aspect-video items-center justify-center"
style={{backgroundColor: ITEM_INVERSE, color: ITEM_INVERSE_INK}}
>
<Play className="size-10 opacity-70 transition-opacity group-hover:opacity-100" aria-hidden="true" />
</div>
{video.caption && (
<p className="px-3 py-2 text-[length:var(--fs-sm)] leading-snug">{video.caption}</p>
)}
</a>
</li>
))}
</ul>
</ItemSection>
);
}

View File

@ -56,35 +56,47 @@ export function ItemSection({
<section
id={id}
aria-labelledby={`${id}-heading`}
className="w-full border-b"
className="border-line paper w-full border-b"
style={{
backgroundColor: dark ? ITEM_INVERSE : 'var(--tpl-surface, #fafafa)',
color: dark ? ITEM_INVERSE_INK : ITEM_INK,
borderColor: 'color-mix(in oklab, currentColor 12%, transparent)',
paddingBlock: 'var(--tpl-section-space, 4rem)',
paddingBlock: 'var(--section-space)',
}}
>
<div className="shell">
<h2 id={`${id}-heading`} className="serif text-2xl tracking-tight sm:text-3xl lg:text-4xl">
{name}
</h2>
{subtitle && <p className="mt-2 text-sm opacity-70">{subtitle}</p>}
<div className="mt-8">{children}</div>
{count && <p className="mt-4 text-xs opacity-55">{count}</p>}
{/* ★ 머리말은 목업(/s/stay 의 아이템 섹션)과 같은 모양이다 — 다른 섹션과 달리
세로 막대를 달지 않는다. 이야기 섹션은 그 자체가 한 덩어리라 구분선이 필요 없다. */}
<header className="mb-8 sm:mb-10">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 id={`${id}-heading`} className="h2">
{name}
</h2>
</div>
{subtitle && (
<p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70">{subtitle}</p>
)}
</header>
{children}
{count && <p className="mt-5 text-[length:var(--fs-xs)] opacity-55">{count}</p>}
</div>
</section>
);
}
/** 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다. */
/**
* 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다.
*
* ★ 클래스 이름(`slider-viewport`/`slider-track`)은 목업과 같게 둔다. 스크롤 스냅만으로
* 같은 동작이 나오므로 캐러셀 라이브러리는 쓰지 않는다.
*/
export function Rail({children, label}: {children: ReactNode; label: string}) {
return (
<div
role="group"
aria-label={label}
className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3"
>
{children}
<div className="relative">
<div className="slider-viewport" role="group" aria-roledescription="캐러셀" aria-label={label} tabIndex={0}>
<div className="slider-track" style={{gap: '1rem'}}>
{children}
</div>
</div>
</div>
);
}

View File

@ -5,6 +5,9 @@
* 한쪽에만 있으면 사장님이 켜 둔 섹션이 발행본에서 말없이 사라진다(HomePage 주석과 같은 함정).
*/
import {SongsSection} from './SongsSection';
import {ItinerarySection} from './ItinerarySection';
import {EventSection} from './EventSection';
import {VideoSection} from './VideoSection';
import {DailySection} from './DailySection';
import {CourseSection} from './CourseSection';
import {ScheduleSection} from './ScheduleSection';
@ -26,4 +29,7 @@ export const ITEM_SECTIONS: Record<string, () => React.ReactElement | null> = {
postcard: PostcardSection,
quiz: QuizSection,
planner: PlannerSection,
itinerary: ItinerarySection,
event: EventSection,
video: VideoSection,
};

View File

@ -107,3 +107,27 @@
transition: none;
}
}
/*
* 날씨 카드의 하늘.
*
* ★ 색을 박지 않는다 — 템플릿이 갱지든 흰 바탕이든 그 위에 얹히는 결이라
* 글자색을 섞어 만든다. data-mood 로 결의 방향과 세기만 바꾼다.
*/
.w4-sky {
position: absolute;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(120% 80% at 80% 0%, color-mix(in oklab, currentColor 7%, transparent) 0%, transparent 60%),
linear-gradient(180deg, color-mix(in oklab, currentColor 4%, transparent) 0%, transparent 55%);
}
.w4-sky[data-mood*="비"],
.w4-sky[data-mood*="눈"] {
background-image:
repeating-linear-gradient(105deg, color-mix(in oklab, currentColor 8%, transparent) 0 1px, transparent 1px 9px),
linear-gradient(180deg, color-mix(in oklab, currentColor 6%, transparent) 0%, transparent 60%);
}
.w4-sky[data-mood*="맑"] {
background-image: radial-gradient(60% 60% at 82% 12%, color-mix(in oklab, currentColor 12%, transparent) 0%, transparent 70%);
}

View File

@ -87,6 +87,9 @@ export function themeStyle(payload: SitePayload): string {
['--tpl-border-width', safe(look.borderWidth)],
['--tpl-radius', safe(look.radius)],
['--tpl-shadow', safe(look.shadow)],
// ★ 종이 질감. `.paper` 가 이 값을 읽는다 — 없으면 섹션 바탕이 평평한 색면으로만 남는다.
// 토큰이 비어 있으면 아무것도 깔지 않는다(질감을 원하지 않는 템플릿이 있다).
['--tpl-texture', safe(look.texture)],
];
for (const [name, value] of pairs) if (value) lines.push(`${name}: ${value};`);
}

View File

@ -22,6 +22,12 @@ const STRUCTURAL = new Set([
'@type',
'@id',
'priceCurrency',
// ★ ㎡의 ISO 코드('MTK'). priceCurrency 와 같은 성격이라 화면에 나올 값이 아니다 —
// 빠져 있어서 객실 면적(room_size)이 있는 사업장은 전부 발행이 막혔다(실측: 가은채 객실 12개).
'unitCode',
// ★ units.length 로 만든 파생 카운트다. 화면에는 객실 카드가 그 개수만큼 있을 뿐
// '12' 라는 숫자가 글자로 있지는 않다. 객실이 2~3개인 사업장만 우연히 통과했다.
'numberOfRooms',
'addressCountry',
'inLanguage',
// 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).