[fix] site: 모달 닫기 스크롤 버그 수정 — 이용안내 체크인·체크아웃 병합, UI 다듬기

모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려
scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다.

- lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정
  (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용)
- sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합
- sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx,
  items/*: 표시 폭·간격·라벨 정리
- lib/ui/Carousel.tsx: loop 이음매 간격 재점검
- scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지
  않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로

tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-18 17:23:07 +09:00
parent 13cc8cc830
commit 4260e20a70
22 changed files with 416 additions and 391 deletions

View File

@ -722,6 +722,7 @@ function prerenderSite(
const MIN_UNIQUE_TEXT = 8; const MIN_UNIQUE_TEXT = 8;
function countUniqueContent(payload: SitePayload): number { function countUniqueContent(payload: SitePayload): number {
// socialPosts는 우리 출력이다. 세면 고유 콘텐츠 0건인 사이트가 자기 소개글로 게이트를 우회한다.
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT; const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
let count = 0; let count = 0;

View File

@ -13,7 +13,7 @@
* `data-slider="on"` . * `data-slider="on"` .
* ** HTML ** . * ** HTML ** .
*/ */
import {useCallback, useEffect, useId, useRef, useState, type ReactNode} from 'react'; import {createContext, useCallback, useContext, useEffect, useId, useRef, useState, type ReactNode} from 'react';
import useEmblaCarousel from 'embla-carousel-react'; import useEmblaCarousel from 'embla-carousel-react';
import {AUTOPLAY_MS, useRailAutoplay} from './use-rail-autoplay'; import {AUTOPLAY_MS, useRailAutoplay} from './use-rail-autoplay';
import {ChevronLeft, ChevronRight} from 'lucide-react'; import {ChevronLeft, ChevronRight} from 'lucide-react';
@ -52,6 +52,14 @@ interface CarouselProps {
* flex `gap` * flex `gap`
* . . * . .
* flex , . * flex , .
* `loop` ( , 2026-09-18)
* Playwright ( ) 0px .
* flex `gap` **** DOM , embla loop
* transform .
* `loop` `gap` 0 , **
* `margin-inline-end`** (`CarouselSlide` `SlideGapContext`) margin
* embla loop .
* `loop=false`() .
*/ */
autoplay?: number | false; autoplay?: number | false;
/** /**
@ -66,6 +74,8 @@ interface CarouselProps {
className?: string; className?: string;
} }
/** `loop` 일 때만 값이 선다(간격을 rem 값으로) — `CarouselSlide` 가 자기 margin 으로 대신 낸다. */
const SlideGapContext = createContext<number | null>(null);
export function Carousel({ export function Carousel({
label, label,
@ -212,8 +222,8 @@ export function Carousel({
} }
}} }}
> >
<div className="slider-track" style={{gap: `${gap}rem`}} id={id}> <div className="slider-track" style={{gap: loop ? 0 : `${gap}rem`}} id={id}>
{children} <SlideGapContext.Provider value={loop ? gap : null}>{children}</SlideGapContext.Provider>
</div> </div>
</div> </div>
@ -264,8 +274,13 @@ export function Carousel({
* "옆으로 더 있다" ** . * "옆으로 더 있다" ** .
*/ */
export function CarouselSlide({basis, children}: {basis: string; children: ReactNode}) { export function CarouselSlide({basis, children}: {basis: string; children: ReactNode}) {
const loopGap = useContext(SlideGapContext);
return ( return (
<div className={`min-w-0 shrink-0 grow-0 ${basis}`} aria-roledescription="슬라이드"> <div
className={`min-w-0 shrink-0 grow-0 ${basis}`}
style={loopGap != null ? {marginInlineEnd: `${loopGap}rem`} : undefined}
aria-roledescription="슬라이드"
>
{children} {children}
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import {useEffect} from 'react'; import {useEffect, useRef} from 'react';
import type {ReactNode} from 'react'; import type {ReactNode} from 'react';
import {X} from 'lucide-react'; import {X} from 'lucide-react';
@ -13,29 +13,47 @@ export function Modal({
open, open,
onClose, onClose,
label, label,
title,
children, children,
wide, wide,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
label: string; label: string;
/** 닫기 버튼과 한 줄에 놓일 제목. 없으면 닫기 버튼만 뜬다. */
title?: ReactNode;
children: ReactNode; children: ReactNode;
/** 안이 2열(정보+예약)로 갈리는 경우처럼 lg 폭이 필요할 때. */ /** 안이 2열(정보+예약)로 갈리는 경우처럼 lg 폭이 필요할 때. */
wide?: boolean; wide?: boolean;
}) { }) {
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose(); if (event.key === 'Escape') onCloseRef.current();
}; };
const previous = document.body.style.overflow; const scrollY = window.scrollY;
document.body.style.overflow = 'hidden'; const body = document.body;
const prevPosition = body.style.position;
const prevTop = body.style.top;
const prevWidth = body.style.width;
const prevOverflow = body.style.overflow;
body.style.position = 'fixed';
body.style.top = `-${scrollY}px`;
body.style.width = '100%';
body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => {
document.body.style.overflow = previous; body.style.position = prevPosition;
body.style.top = prevTop;
body.style.width = prevWidth;
body.style.overflow = prevOverflow;
window.scrollTo(0, scrollY);
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
}; };
}, [open, onClose]); }, [open]);
if (!open) return null; if (!open) return null;
@ -51,16 +69,18 @@ export function Modal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={label} aria-label={label}
className={`absolute inset-x-0 bottom-0 max-h-[88svh] overflow-auto rounded-t-2xl px-5 pb-8 pt-14 sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:max-h-[85vh] sm:w-full sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl ${ className={`absolute inset-x-0 bottom-0 flex max-h-[88svh] flex-col overflow-hidden rounded-t-2xl sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:max-h-[85vh] sm:w-full sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl ${
wide ? 'sm:max-w-3xl' : 'sm:max-w-lg' wide ? 'sm:max-w-3xl' : 'sm:max-w-lg'
}`} }`}
style={{backgroundColor: 'var(--color-surface)'}} style={{backgroundColor: 'var(--color-surface)'}}
> >
<div className="border-line flex shrink-0 items-center justify-between gap-3 border-b p-5 pb-4">
{title && <h3 className="h3 min-w-0 truncate">{title}</h3>}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="닫기" aria-label="닫기"
className="tap absolute right-3 top-3 z-10 flex size-10 items-center justify-center rounded-full shadow-md" className="tap ml-auto flex size-10 shrink-0 items-center justify-center rounded-full shadow-md"
style={{ style={{
backgroundColor: 'var(--color-surface)', backgroundColor: 'var(--color-surface)',
boxShadow: '0 1px 2px rgba(0,0,0,.18), 0 0 0 1px color-mix(in oklab, currentColor 12%, transparent)', boxShadow: '0 1px 2px rgba(0,0,0,.18), 0 0 0 1px color-mix(in oklab, currentColor 12%, transparent)',
@ -68,7 +88,8 @@ export function Modal({
> >
<X className="size-5" /> <X className="size-5" />
</button> </button>
{children} </div>
<div className="overflow-y-auto p-5 pb-8">{children}</div>
</div> </div>
</div> </div>
); );

View File

@ -1,72 +1,115 @@
import {useState} from 'react'; import {useState} from 'react';
import type {PostEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {formatKoreanDate} from '@site/lib/format'; import {isoDate} from '@site/lib/format';
import {Section} from '@site/lib/ui'; import {Carousel, CarouselSlide, Modal, Section} from '@site/lib/ui';
/** /**
* . 기획: docs/MINI_BLOG.md * . 기획: docs/MINI_BLOG.md
* *
* HTML . * HTML .
* 2 . * (`Carousel` )
* ( ). . * .
* ( ). · · 1
* 2026-09-18 : "UI가 좀 유치한 것 같은데" , `tpl-border`
* ( ) "border2 블랙 촌스럽다"
* (`SpotCard`) `.panel`( ·) ,
* (
* `SongsSection.tsx` `LABEL_PALETTE` ).
* (line-clamp)
* (2026-09-18 : "클릭했을 때 모달도 안 띄우네" `SpotCard`/`ReviewSection`
* `Modal` ). HTML
* .
* ** (= ) , `Section` **
* (2026-09-18 :
* "나랑 장난하니?", "스크롤이 자꾸 없어지지?"). embla `.slider-track`
* `transform` , `position: fixed` `transform`
* ** ** (CSS containing block ).
* `overflow-x:hidden` ,
* `SpotCard`(, ) .
* id , `Modal` `Carousel` .
* loop `Carousel` (gap) .
* embla loop (2026-09-09 ,
* `Carousel.tsx` )
* .
*/ */
const PER_PAGE = 10; type Season = 'spr' | 'sum' | 'aut' | 'win';
const SEASON_COLOR: Record<Season, string> = {
spr: '#4f7942',
sum: '#c9820a',
aut: '#bf2f1b',
win: '#3b6ea5',
};
function seasonOf(month: number): Season {
if (month >= 3 && month <= 5) return 'spr';
if (month >= 6 && month <= 8) return 'sum';
if (month >= 9 && month <= 11) return 'aut';
return 'win';
}
/** ISO → 한국 시간 기준 월·일·요일. `isoDate` 와 같은 이유로 타임존을 명시한다. */
function kstDateParts(iso: string) {
const [, month, day] = isoDate(iso).split('-').map(Number);
const weekday = new Intl.DateTimeFormat('ko-KR', {weekday: 'long', timeZone: 'Asia/Seoul'}).format(new Date(iso));
return {month, day, weekday};
}
export function BlogSection() { export function BlogSection() {
const payload = useSite(); const payload = useSite();
const posts = payload.posts ?? []; const posts = payload.posts ?? [];
const [page, setPage] = useState(0); const [openId, setOpenId] = useState<string | null>(null);
if (posts.length === 0) return null; if (posts.length === 0) return null;
const pages = Math.ceil(posts.length / PER_PAGE); const openPost = posts.find((post) => post.postId === openId) ?? null;
const openDate = openPost ? kstDateParts(openPost.publishedAt) : null;
const openLabel = openDate ? `${openDate.month}${openDate.day}일 · ${openDate.weekday}` : '';
return ( return (
<Section <Section
id="blog" id="blog"
tone="alt" tone="alt"
title={`${payload.place.name}의 기록`} title="미니 블로그"
lead="사장님이 띄엄띄엄 남기는 짧은 글입니다." lead="사장님이 남기는 숙소 이야기입니다."
> >
<ul className="divide-line divide-y"> <Carousel label={`${payload.place.name} 미니 블로그`} align="start" gap={0.875} loop>
{/* 전부 그리고 이번 장이 아닌 것만 감춘다 — 잘라내면 구운 HTML 에 안 남는다. */} {posts.map((post) => (
{posts.map((post, index) => ( <CarouselSlide key={post.postId} basis="basis-[225px]">
<li <PostCard post={post} onOpen={() => setOpenId(post.postId)} />
key={post.postId} </CarouselSlide>
hidden={Math.floor(index / PER_PAGE) !== page}
className="flex flex-col gap-1.5 py-4"
>
<time
dateTime={post.publishedAt}
className="text-muted text-[length:var(--fs-xs)] tabular-nums"
>
{formatKoreanDate(post.publishedAt)}
</time>
<p className="measure whitespace-pre-line break-keep text-[length:var(--fs-body)] leading-relaxed">
{post.body}
</p>
</li>
))} ))}
</ul> </Carousel>
{pages > 1 && ( <Modal open={openPost != null} onClose={() => setOpenId(null)} label={`미니 블로그 · ${openLabel}`} title={openLabel}>
<nav aria-label="글 페이지" className="mt-5 flex flex-wrap justify-center gap-1.5"> {openPost && (
{Array.from({length: pages}, (_, index) => ( <p className="whitespace-pre-line break-keep text-[length:var(--fs-body)] leading-relaxed">{openPost.body}</p>
<button
key={index}
type="button"
onClick={() => setPage(index)}
aria-current={index === page ? 'page' : undefined}
className="border-line tpl-border min-w-9 rounded border px-3 py-1.5 text-[length:var(--fs-sm)] font-semibold tabular-nums"
style={index === page
? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}
: undefined}
>
{index + 1}
</button>
))}
</nav>
)} )}
</Modal>
</Section> </Section>
); );
} }
function PostCard({post, onOpen}: {post: PostEntry; onOpen: () => void}) {
const {month, day, weekday} = kstDateParts(post.publishedAt);
const season = seasonOf(month);
const color = SEASON_COLOR[season];
const dateLabel = `${month}${day}일 · ${weekday}`;
return (
<button
type="button"
onClick={onOpen}
className="panel flex aspect-square w-full flex-col gap-2 p-4 text-left"
style={{backgroundColor: `color-mix(in srgb, ${color} 9%, var(--tpl-card, #efe7d3))`}}
>
<time dateTime={post.publishedAt} className="block shrink-0 text-[length:var(--fs-xs)] font-bold tracking-wide" style={{color}}>
{dateLabel}
</time>
<p className="line-clamp-5 min-h-0 flex-1 whitespace-pre-line break-keep text-[length:var(--fs-xs)] leading-relaxed">
{post.body}
</p>
</button>
);
}

View File

@ -99,7 +99,7 @@ export function BookingRequestSection({stay, guests}: {stay?: string; guests?: s
rows={3} rows={3}
maxLength={1000} maxLength={1000}
placeholder="늦은 도착, 주차 대수 등" placeholder="늦은 도착, 주차 대수 등"
className="border-line w-full rounded border-2 p-3 text-[length:var(--fs-body)]" className="border-line w-full min-w-0 rounded border p-2.5 text-[length:var(--fs-sm)]"
style={{backgroundColor: 'var(--color-surface)'}} style={{backgroundColor: 'var(--color-surface)'}}
/> />
</div> </div>
@ -163,7 +163,7 @@ function Field({id, label, name, placeholder, type = 'text', required, hint}: {
required={required} required={required}
maxLength={60} maxLength={60}
placeholder={placeholder} placeholder={placeholder}
className="border-line w-full rounded border-2 p-3 text-[length:var(--fs-body)]" className="border-line w-full min-w-0 rounded border p-2.5 text-[length:var(--fs-sm)]"
style={{backgroundColor: 'var(--color-surface)'}} style={{backgroundColor: 'var(--color-surface)'}}
/> />
{hint && <p className="text-muted text-[length:var(--fs-xs)]">{hint}</p>} {hint && <p className="text-muted text-[length:var(--fs-xs)]">{hint}</p>}

View File

@ -1,7 +1,7 @@
import {Check, Phone, X} from 'lucide-react'; import {Check, X} from 'lucide-react';
import {selectPublishable} from '@o2o/shared'; import {selectPublishable} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, essentialRows} from '@site/lib/derive'; import {essentialRows} from '@site/lib/derive';
import {factualSummary} from '@site/seo/meta'; import {factualSummary} from '@site/seo/meta';
import type {InfoRow} from '@site/lib/derive'; import type {InfoRow} from '@site/lib/derive';
import {Section} from '@site/lib/ui'; import {Section} from '@site/lib/ui';
@ -34,9 +34,8 @@ const AMENITY_VALUES = new Set(['가능', '불가', '있음', '없음']);
const isAmenity = (row: InfoRow) => !row.note && AMENITY_VALUES.has(row.value.trim()); const isAmenity = (row: InfoRow) => !row.note && AMENITY_VALUES.has(row.value.trim());
const TWO_LINE_LABELS = new Set(['체크인 시간', '체크아웃 시간']);
const RULE_LABELS = new Set([ const RULE_LABELS = new Set([
'체크인 · 체크아웃',
'체크인 시간', '체크인 시간',
'체크아웃 시간', '체크아웃 시간',
'취소·환불 규정', '취소·환불 규정',
@ -45,6 +44,19 @@ const RULE_LABELS = new Set([
'흡연 가능', '흡연 가능',
'인원 추가 요금', '인원 추가 요금',
]); ]);
/** 체크인·체크아웃은 둘이 한 쌍이라 줄을 나눠 봐야 비교만 어렵다 — 한 줄로 합친다. */
function mergeCheckInOut(rows: InfoRow[]): InfoRow[] {
const checkIn = rows.find((row) => row.key === 'check_in_time');
const checkOut = rows.find((row) => row.key === 'check_out_time');
if (!checkIn || !checkOut) return rows;
return rows
.map((row) => (row.key === 'check_in_time'
? {key: row.key, label: '체크인 · 체크아웃', value: `${checkIn.value} · ${checkOut.value}`}
: row))
.filter((row) => row.key !== 'check_out_time');
}
export function EssentialInfoSection() { export function EssentialInfoSection() {
const payload = useSite(); const payload = useSite();
const rows = essentialRows(payload); const rows = essentialRows(payload);
@ -53,7 +65,7 @@ export function EssentialInfoSection() {
const guides = payload.links.filter((link) => link.confirmed && const guides = payload.links.filter((link) => link.confirmed &&
[link.stayGuide?.policy, link.stayGuide?.service, link.stayGuide?.reservation].some((text) => text?.trim())); [link.stayGuide?.policy, link.stayGuide?.service, link.stayGuide?.reservation].some((text) => text?.trim()));
const structured = guides.flatMap((link) => link.stayGuide?.fields ?? []); const structured = guides.flatMap((link) => link.stayGuide?.fields ?? []);
const mergedRows = [...rows]; const mergedRows = mergeCheckInOut([...rows]);
const seen = new Set(rows.map((row) => row.key)); const seen = new Set(rows.map((row) => row.key));
for (const field of structured) { for (const field of structured) {
// 직접 입력한 노출값을 우선한다. 같은 항목을 출처마다 반복하지 않는다. // 직접 입력한 노출값을 우선한다. 같은 항목을 출처마다 반복하지 않는다.
@ -88,7 +100,7 @@ export function EssentialInfoSection() {
/* (2026-09-03, ) /* (2026-09-03, )
"이용 및 예약 안내" "예약 안내" "이용 및 예약 안내" "예약 안내"
. . */ . . */
title="이용안내 및 예약" title="이용안내"
lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다." lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다."
> >
<div className="space-y-8"> <div className="space-y-8">
@ -102,7 +114,6 @@ export function EssentialInfoSection() {
{notices.map((text) => ( {notices.map((text) => (
<ReservationNotice key={text.slice(0, 32)} text={text} /> <ReservationNotice key={text.slice(0, 32)} text={text} />
))} ))}
<BookingRow />
</div> </div>
</Section> </Section>
); );
@ -143,7 +154,7 @@ function Rows({title, rows, emphasis, children}: {
{rows.length > 0 && ( {rows.length > 0 && (
<dl className="divide-line divide-y"> <dl className="divide-line divide-y">
{rows.map((row, index) => { {rows.map((row, index) => {
const short = !row.note && row.value.length <= 24 && !TWO_LINE_LABELS.has(row.label); const short = !row.note && row.value.length <= 24;
return ( return (
<div <div
key={`${row.label}-${index}`} key={`${row.label}-${index}`}
@ -219,47 +230,3 @@ function ReservationNotice({text}: {text: string}) {
); );
} }
/**
* .
*
* '예약 안내' . ** **
* .
* . URL .
*/
function BookingRow() {
const payload = useSite();
const links = bookingLinks(payload);
const phone = payload.place.phone;
if (!phone && links.length === 0) return null;
return (
<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" />
<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-100"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{bookingActionLabel(link)}
</a>
))}
</div>
</div>
);
}

View File

@ -210,7 +210,7 @@ function FestivalRail({
))} ))}
</Carousel> </Carousel>
<Modal open={open != null} onClose={() => setOpen(null)} label={open?.name ?? '축제'}> <Modal open={open != null} onClose={() => setOpen(null)} label={open?.name ?? '축제'} title={open?.name}>
{open && ( {open && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{open.imageUrl && ( {open.imageUrl && (
@ -220,7 +220,6 @@ function FestivalRail({
className="aspect-4/3 w-full rounded-lg object-cover" className="aspect-4/3 w-full rounded-lg object-cover"
/> />
)} )}
<h3 className="h3">{open.name}</h3>
{open.period && ( {open.period && (
<p className="text-[length:var(--fs-sm)] font-semibold opacity-100">{open.period}</p> <p className="text-[length:var(--fs-sm)] font-semibold opacity-100">{open.period}</p>
)} )}

View File

@ -1,9 +1,8 @@
import {useCallback, useEffect, useRef, useState} from 'react'; import {useCallback, useEffect, useState} from 'react';
import {ChevronLeft, ChevronRight, X} from 'lucide-react'; import {ChevronLeft, ChevronRight, X} from 'lucide-react';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {galleryImages} from '@site/lib/derive'; import {galleryImages} from '@site/lib/derive';
import {Section} from '@site/lib/ui'; import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
import {AUTOPLAY_MS, scrollRailNext, useRailAutoplay} from '@site/lib/ui/use-rail-autoplay';
/** /**
* . * .
@ -19,45 +18,6 @@ export function GallerySection() {
const variantId = setting?.variantId ?? 'photos.grid'; const variantId = setting?.variantId ?? 'photos.grid';
const [openIndex, setOpenIndex] = useState<number | null>(null); const [openIndex, setOpenIndex] = useState<number | null>(null);
// 좁은 화면 캐러셀의 현재 장 · 양끝 여부. 넓은 화면은 격자라 쓰이지 않는다.
const track = useRef<HTMLUListElement>(null);
const [slide, setSlide] = useState(0);
const [edge, setEdge] = useState({prev: false, next: true});
const syncTrack = useCallback(() => {
const box = track.current;
if (!box) return;
const step = box.clientWidth;
// 소수점 폭 때문에 scrollLeft 가 끝에 정확히 닿지 못한다 — 8px 여유를 둔다.
const max = box.scrollWidth - step;
setSlide(step > 0 ? Math.round(box.scrollLeft / step) : 0);
setEdge({prev: box.scrollLeft > 8, next: box.scrollLeft < max - 8});
}, []);
useEffect(() => {
syncTrack();
window.addEventListener('resize', syncTrack);
return () => window.removeEventListener('resize', syncTrack);
}, [syncTrack]);
/*
* (`useRailAutoplay`).
* , `scrollRailNext` false .
* ( 1).
*/
useRailAutoplay({
box: track,
interval: AUTOPLAY_MS,
advance: () => scrollRailNext(track.current, 1),
});
const nudge = useCallback((dir: -1 | 1) => {
const box = track.current;
if (!box) return;
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
box.scrollBy({left: dir * box.clientWidth, behavior: still ? 'auto' : 'smooth'});
}, []);
const close = useCallback(() => setOpenIndex(null), []); const close = useCallback(() => setOpenIndex(null), []);
const step = useCallback( const step = useCallback(
(delta: number) => (delta: number) =>
@ -76,11 +36,23 @@ export function GallerySection() {
if (event.key === 'ArrowLeft') step(-1); if (event.key === 'ArrowLeft') step(-1);
if (event.key === 'ArrowRight') step(1); if (event.key === 'ArrowRight') step(1);
}; };
const previous = document.body.style.overflow; const scrollY = window.scrollY;
document.body.style.overflow = 'hidden'; const body = document.body;
const prevPosition = body.style.position;
const prevTop = body.style.top;
const prevWidth = body.style.width;
const prevOverflow = body.style.overflow;
body.style.position = 'fixed';
body.style.top = `-${scrollY}px`;
body.style.width = '100%';
body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => {
document.body.style.overflow = previous; body.style.position = prevPosition;
body.style.top = prevTop;
body.style.width = prevWidth;
body.style.overflow = prevOverflow;
window.scrollTo(0, scrollY);
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
}; };
}, [openIndex, close, step]); }, [openIndex, close, step]);
@ -91,29 +63,26 @@ export function GallerySection() {
<Section id="gallery" title={setting?.name || '공간 갤러리'}> <Section id="gallery" title={setting?.name || '공간 갤러리'}>
{/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */} {/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */}
{variantId === 'photos.carousel' ? ( {variantId === 'photos.carousel' ? (
/* <>
* . {/*
* , . . CSS
* CSS . ( ) . */}
* matchMedia . <div className="lg:hidden">
* . <Carousel label="공간 갤러리" align="center" gap={0} arrows="overlay">
*
* (2026-09-04, : "모바일 처리가 안 돼 있다")
* 82% ,
* ** .**
* , ** (n/N) ** .
* "더 있다" .
*/
<div className="relative">
<ul
ref={track}
onScroll={syncTrack}
/* 12px 8px· 12px
( 16px, Carousel ). */
className="flex snap-x snap-mandatory gap-3 overflow-x-auto [touch-action:pan-y_pinch-zoom] [scrollbar-width:none] lg:grid lg:grid-cols-4 lg:gap-3 lg:snap-none lg:overflow-visible [&::-webkit-scrollbar]:hidden"
>
{images.map((image, index) => ( {images.map((image, index) => (
<li key={image.mediaId} className="w-full shrink-0 snap-center lg:w-auto"> <CarouselSlide key={image.mediaId} basis="basis-full">
<GalleryImage
image={image}
onOpen={() => setOpenIndex(index)}
className="aspect-4/3 rounded-lg"
/>
</CarouselSlide>
))}
</Carousel>
</div>
<ul className="hidden lg:grid lg:grid-cols-4 lg:gap-3">
{images.map((image, index) => (
<li key={image.mediaId}>
<GalleryImage <GalleryImage
image={image} image={image}
onOpen={() => setOpenIndex(index)} onOpen={() => setOpenIndex(index)}
@ -122,18 +91,7 @@ export function GallerySection() {
</li> </li>
))} ))}
</ul> </ul>
{/* 좌우 버튼 · 장수 — 격자가 되는 넓은 화면에서는 없앤다(밀 것이 없다). */}
{images.length > 1 && (
<>
<TrackNav dir="prev" show={edge.prev} onClick={() => nudge(-1)} />
<TrackNav dir="next" show={edge.next} onClick={() => nudge(1)} />
<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">
{Math.min(slide + 1, images.length)} / {images.length}
</span>
</> </>
)}
</div>
) : variantId === 'photos.masonry' ? ( ) : variantId === 'photos.masonry' ? (
<ul className="columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3"> <ul className="columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3">
{images.map((image, index) => ( {images.map((image, index) => (
@ -203,24 +161,6 @@ export function GallerySection() {
); );
} }
/** 캐러셀 좌우 버튼 — 끝에 닿으면 지운다. 눌러도 안 움직이는 버튼은 고장으로 읽힌다. */
function TrackNav({dir, show, onClick}: {dir: 'prev' | 'next'; show: boolean; onClick: () => void}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
aria-label={dir === 'prev' ? '이전 사진' : '다음 사진'}
aria-hidden={!show}
tabIndex={show ? 0 : -1}
className={`absolute 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 ${
show ? 'opacity-100' : 'pointer-events-none opacity-0'
} ${dir === 'prev' ? 'left-2' : 'right-2'}`}
>
<Icon className="size-5" />
</button>
);
}
function LightboxNav({dir, onClick}: {dir: 'prev' | 'next'; onClick: () => void}) { function LightboxNav({dir, onClick}: {dir: 'prev' | 'next'; onClick: () => void}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight; const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;

View File

@ -248,7 +248,7 @@ function PlaceList({
))} ))}
</Carousel> </Carousel>
<Modal open={open != null} onClose={() => setOpen(null)} label={open?.name ?? '장소'}> <Modal open={open != null} onClose={() => setOpen(null)} label={open?.name ?? '장소'} title={open?.name}>
{open && ( {open && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{open.imageUrl && ( {open.imageUrl && (
@ -258,7 +258,6 @@ function PlaceList({
className="aspect-[16/10] w-full rounded-lg object-cover" className="aspect-[16/10] w-full rounded-lg object-cover"
/> />
)} )}
<h3 className="h3">{open.name}</h3>
{(Number.isFinite(distanceOf(open)) || open.distanceText) && ( {(Number.isFinite(distanceOf(open)) || open.distanceText) && (
<p className="text-[length:var(--fs-sm)] font-semibold opacity-100"> <p className="text-[length:var(--fs-sm)] font-semibold opacity-100">
{walkText(distanceOf(open)) ?? open.distanceText} {walkText(distanceOf(open)) ?? open.distanceText}

View File

@ -1,6 +1,10 @@
import {useState} from 'react';
import {MapPin, MessageCircle, Phone} from 'lucide-react'; import {MapPin, MessageCircle, Phone} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, channelLabel} from '@site/lib/derive'; import {bookingActionLabel, bookingLinks, channelLabel, stayBookingView} from '@site/lib/derive';
import {Modal} from '@site/lib/ui';
import {StayBookingDemo} from './StayBookingDemo';
/** /**
* . * .
@ -19,10 +23,13 @@ export function MobileTabBar() {
const links = bookingLinks(payload); const links = bookingLinks(payload);
const booking = links[0]; const booking = links[0];
const kakao = payload.links.find((link) => link.confirmed && /kakao/i.test(link.url)); const kakao = payload.links.find((link) => link.confirmed && /kakao/i.test(link.url));
const stayBooking = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const [bookingOpen, setBookingOpen] = useState(false);
if (!phone && !booking && !kakao) return null; if (!phone && !booking && !kakao) return null;
return ( return (
<>
<nav <nav
aria-label="연락 · 예약" aria-label="연락 · 예약"
className="border-line safe-b fixed inset-x-0 bottom-0 z-50 flex items-stretch gap-2 border-t px-3 pt-2 backdrop-blur-md lg:hidden" className="border-line safe-b fixed inset-x-0 bottom-0 z-50 flex items-stretch gap-2 border-t px-3 pt-2 backdrop-blur-md lg:hidden"
@ -61,6 +68,16 @@ export function MobileTabBar() {
)} )}
{booking && ( {booking && (
stayBooking ? (
<button
type="button"
onClick={() => setBookingOpen(true)}
className="tap flex min-w-0 flex-[1.4] items-center justify-center truncate rounded-lg px-2 text-[length:var(--fs-sm)] font-bold"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
</button>
) : (
<a <a
href={booking.url} href={booking.url}
target="_blank" target="_blank"
@ -72,7 +89,15 @@ export function MobileTabBar() {
390px . '어디서 예약하나' . */} 390px . '어디서 예약하나' . */}
{bookingActionLabel(booking)} {bookingActionLabel(booking)}
</a> </a>
)
)} )}
</nav> </nav>
{stayBooking && (
<Modal open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" title="예약 요청" wide>
<StayBookingDemo />
</Modal>
)}
</>
); );
} }

View File

@ -1,7 +1,7 @@
import {useCallback, useEffect, useRef, useState} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {formatKoreanDate} from '@site/lib/format'; import {formatKoreanDate} from '@site/lib/format';
import {Section} from '@site/lib/ui'; import {Modal, Section} from '@site/lib/ui';
/** /**
* . * .
@ -48,18 +48,7 @@ export function ReviewSection() {
}, [refresh]); }, [refresh]);
useEffect(() => { useEffect(() => {
if (!open) return; if (open) openedAt.current = Date.now();
openedAt.current = Date.now();
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = previous;
window.removeEventListener('keydown', onKey);
};
}, [open]); }, [open]);
async function submit(event: React.FormEvent<HTMLFormElement>) { async function submit(event: React.FormEvent<HTMLFormElement>) {
@ -102,7 +91,7 @@ export function ReviewSection() {
id="reviews" id="reviews"
tone="alt" tone="alt"
title="다녀오신 이야기" title="다녀오신 이야기"
lead="점수 대신 문장으로 남겨 주세요." lead="다녀가신 분들이 남긴 이야기입니다."
> >
{reviews.length > 0 && ( {reviews.length > 0 && (
<ul className="mb-7 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <ul className="mb-7 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@ -166,23 +155,8 @@ export function ReviewSection() {
</button> </button>
{open && ( <Modal open={open} onClose={() => setOpen(false)} label="후기 남기기" title="후기 남기기">
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="닫기"
onClick={() => setOpen(false)}
className="absolute inset-0 bg-black/55"
/>
<div
role="dialog"
aria-modal="true"
aria-label="후기 남기기"
className="absolute inset-x-0 bottom-0 max-h-[88svh] overflow-auto rounded-t-2xl p-5 pb-8"
style={{backgroundColor: 'var(--color-surface)'}}
>
<form onSubmit={submit} className="flex flex-col gap-3"> <form onSubmit={submit} className="flex flex-col gap-3">
<p className="text-[length:var(--fs-body)] font-bold"> </p>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label htmlFor="review-body" className="text-[length:var(--fs-sm)] font-bold"></label> <label htmlFor="review-body" className="text-[length:var(--fs-sm)] font-bold"></label>
@ -197,7 +171,7 @@ export function ReviewSection() {
style={{backgroundColor: 'var(--color-surface)'}} style={{backgroundColor: 'var(--color-surface)'}}
/> />
<p className="text-muted text-[length:var(--fs-xs)] tabular-nums"> <p className="text-muted text-[length:var(--fs-xs)] tabular-nums">
{text.length} / {MAX_LEN} · {text.length} / {MAX_LEN}
</p> </p>
</div> </div>
@ -245,21 +219,8 @@ export function ReviewSection() {
{sending ? '보내는 중…' : '후기 보내기'} {sending ? '보내는 중…' : '후기 보내기'}
</button> </button>
<p className="text-muted text-[length:var(--fs-xs)]">
. .
</p>
<button
type="button"
onClick={() => setOpen(false)}
className="tap text-muted text-[length:var(--fs-sm)]"
>
</button>
</form> </form>
</div> </Modal>
</div>
)}
</Section> </Section>
); );
} }

View File

@ -1,8 +1,12 @@
import {useState} from 'react';
import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react'; import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
import {isoDate} from '@site/lib/format'; import {isoDate} from '@site/lib/format';
// 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다. // 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다.
import {channelLabel, primaryChannelLink} from '@site/lib/derive'; import {bookingLabel, primaryChannelLink, stayBookingView} from '@site/lib/derive';
import {Modal} from '@site/lib/ui';
import {StayBookingDemo} from './StayBookingDemo';
/** /**
* . * .
@ -17,6 +21,8 @@ export function SiteFooter() {
// ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고). // ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
const link = primaryChannelLink(payload); const link = primaryChannelLink(payload);
const address = place.roadAddress ?? place.address; const address = place.roadAddress ?? place.address;
const booking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const [bookingOpen, setBookingOpen] = useState(false);
return ( return (
<footer <footer
@ -62,15 +68,25 @@ export function SiteFooter() {
<h2 className="label mb-3 !text-current opacity-100"> </h2> <h2 className="label mb-3 !text-current opacity-100"> </h2>
<ul className="flex flex-wrap gap-2"> <ul className="flex flex-wrap gap-2">
<li> <li>
{booking ? (
<button
type="button"
onClick={() => setBookingOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10"
>
<span></span>
</button>
) : (
<a <a
href={link.url} href={link.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10" className="inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10"
> >
<span>{channelLabel(link)}</span> <span>{bookingLabel(link)}</span>
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
</a> </a>
)}
</li> </li>
</ul> </ul>
</nav> </nav>
@ -129,6 +145,12 @@ export function SiteFooter() {
Web4Ai로 . Web4Ai로 .
</p> </p>
</div> </div>
{booking && (
<Modal open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" title="예약 요청" wide>
<StayBookingDemo />
</Modal>
)}
</footer> </footer>
); );
} }

View File

@ -21,8 +21,8 @@ export function SiteHeader() {
(local)' . */ (local)' . */
const items = [ const items = [
{label: '소개', href: '#about', show: isSectionEnabled(payload, 'intro')}, {label: '소개', href: '#about', show: isSectionEnabled(payload, 'intro')},
{label: spec.label, href: '#units', show: payload.units.length > 0},
{label: '이용 정보', href: '#info', show: true}, {label: '이용 정보', href: '#info', show: true},
{label: spec.label, href: '#units', show: payload.units.length > 0},
{label: '오시는 길', href: '#location', show: true}, {label: '오시는 길', href: '#location', show: true},
{label: '축제', href: '#festival', show: (payload.local.festivals?.length ?? 0) > 0}, {label: '축제', href: '#festival', show: (payload.local.festivals?.length ?? 0) > 0},
{label: '주변 정보', href: '#guide', show: isSectionEnabled(payload, 'local')}, {label: '주변 정보', href: '#guide', show: isSectionEnabled(payload, 'local')},

View File

@ -33,6 +33,7 @@ export function UnitsSection() {
const spec = unitSpec(payload); const spec = unitSpec(payload);
const booking = stayBookingView(payload); const booking = stayBookingView(payload);
const [openUnit, setOpenUnit] = useState<string | null>(null); const [openUnit, setOpenUnit] = useState<string | null>(null);
const [bookingOpen, setBookingOpen] = useState(false);
// ★ 격자는 '고르는 화면', 밴드는 '보여 주는 화면'이다. 독채 두 동에는 밴드가 맞다. // ★ 격자는 '고르는 화면', 밴드는 '보여 주는 화면'이다. 독채 두 동에는 밴드가 맞다.
const layout = useLayout(); const layout = useLayout();
if (layout === 'reservation') return <ReservationRooms />; if (layout === 'reservation') return <ReservationRooms />;
@ -133,12 +134,9 @@ export function UnitsSection() {
open={openUnit === unit.unitId} open={openUnit === unit.unitId}
onClose={() => setOpenUnit(null)} onClose={() => setOpenUnit(null)}
label={`${unit.name} 상세 · 예약`} label={`${unit.name} 상세 · 예약`}
wide={Boolean(booking)} title={unit.name}
> >
<div className={`grid grid-cols-1 gap-6 ${booking ? 'sm:grid-cols-2' : ''}`}>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<h3 className="h3">{unit.name}</h3>
{offer?.baseRateText && ( {offer?.baseRateText && (
<p className="text-[length:var(--fs-lead)] font-bold" style={{color: 'var(--color-brand)'}}> <p className="text-[length:var(--fs-lead)] font-bold" style={{color: 'var(--color-brand)'}}>
{offer.baseRateText} {offer.baseRateText}
@ -176,17 +174,22 @@ export function UnitsSection() {
))} ))}
</dl> </dl>
)} )}
</div>
{booking && ( {booking && (
<div className="flex flex-col gap-4"> <div className="border-line flex flex-col gap-2 border-t pt-4">
<div className="flex flex-col gap-2">
<p className="text-[length:var(--fs-sm)] font-bold"></p> <p className="text-[length:var(--fs-sm)] font-bold"></p>
<button
type="button"
onClick={() => { setOpenUnit(null); setBookingOpen(true); }}
className="tap flex items-center justify-center gap-2 rounded-lg px-4 text-[length:var(--fs-sm)] font-bold"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
<span> </span>
</button>
{booking.phone && ( {booking.phone && (
<a <a
href={`tel:${booking.phone}`} href={`tel:${booking.phone}`}
className="tap flex items-center justify-center gap-2 rounded-lg px-4 text-[length:var(--fs-sm)] font-bold" className="tap border-line tpl-border flex items-center justify-center gap-2 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
<Phone className="size-4" /> <Phone className="size-4" />
<span> {booking.phone}</span> <span> {booking.phone}</span>
@ -205,16 +208,17 @@ export function UnitsSection() {
</a> </a>
))} ))}
</div> </div>
{/* +
(PRODUCT.md 6), StayBookingDemo . */}
<StayBookingDemo />
</div>
)} )}
</div> </div>
</Modal> </Modal>
); );
})} })}
{booking && (
<Modal open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" title="예약 요청" wide>
<StayBookingDemo />
</Modal>
)}
</Section> </Section>
); );
} }

View File

@ -83,7 +83,7 @@ export function WeatherSection() {
</span> </span>
</p> </p>
{observed && ( {observed && (
<span className="text-muted text-[length:var(--fs-xs)] tabular-nums"> <span className="text-[length:var(--fs-sm)] tabular-nums">
{observed} {observed}
{weather.stale && ' · 최근 관측값'} {weather.stale && ' · 최근 관측값'}
</span> </span>
@ -110,7 +110,7 @@ export function WeatherSection() {
className="w4-note-fade measure flex items-start gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-100" className="w4-note-fade measure flex items-start gap-2.5 text-[length:var(--fs-sm)] leading-relaxed opacity-100"
> >
<span <span
className="border-line mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold" className="border-line mt-0.5 shrink-0 rounded-full border px-2 py-0.5 text-[length:var(--fs-sm)] font-bold"
aria-hidden aria-hidden
> >
{band} {band}

View File

@ -30,7 +30,15 @@ it('구운 HTML 에 글이 전부 들어간다 — 크롤러는 2페이지를
for (let i = 0; i < 23; i += 1) expect(html).toContain(`${i}번째 글입니다`); for (let i = 0; i < 23; i += 1) expect(html).toContain(`${i}번째 글입니다`);
}); });
it('열 개를 넘으면 페이지 번호가 선다', () => { it('글 개수와 무관하게 카로셀 하나로 뜬다 — 페이지를 끊지 않는다', () => {
expect(render(withPosts(23))).toContain('aria-label="글 페이지"'); expect(render(withPosts(23))).toContain('slider-viewport');
expect(render(withPosts(4))).not.toContain('aria-label="글 페이지"'); expect(render(withPosts(4))).toContain('slider-viewport');
expect(render(withPosts(23))).not.toContain('aria-label="글 페이지"');
});
it('날짜 라벨에 월·일·요일이 찍힌다', () => {
const html = render(withPosts(1));
expect(html).toContain('2026-09-01T09:00:00');
expect(html).toContain('9월 1일');
expect(html).toMatch(/요일/);
}); });

View File

@ -95,11 +95,23 @@ export function EventSection() {
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') close(); if (event.key === 'Escape') close();
}; };
const previous = document.body.style.overflow; const scrollY = window.scrollY;
document.body.style.overflow = 'hidden'; const body = document.body;
const prevPosition = body.style.position;
const prevTop = body.style.top;
const prevWidth = body.style.width;
const prevOverflow = body.style.overflow;
body.style.position = 'fixed';
body.style.top = `-${scrollY}px`;
body.style.width = '100%';
body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => {
document.body.style.overflow = previous; body.style.position = prevPosition;
body.style.top = prevTop;
body.style.width = prevWidth;
body.style.overflow = prevOverflow;
window.scrollTo(0, scrollY);
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
}; };
}, [open, close]); }, [open, close]);

View File

@ -45,15 +45,6 @@ function daysOf(item: ItineraryItem): {label?: string; startTime?: string; stops
return [{startTime: item.startTime, stops: item.stops ?? []}]; return [{startTime: item.startTime, stops: item.stops ?? []}];
} }
/** 같은 코스의 날짜 카드 사이 점선 이음줄. 레일 안 다른 카드와 같은 flex 흐름을 탄다. */
function DayConnector() {
return (
<div className="flex w-6 shrink-0 items-center self-stretch" aria-hidden>
<span className="w-full border-t-2 border-dashed" style={{borderColor: ITEM_BORDER}} />
</div>
);
}
function DayCard({ function DayCard({
item, item,
badge, badge,
@ -61,6 +52,8 @@ function DayCard({
stops, stops,
first, first,
placeName, placeName,
continuesFrom,
continuesTo,
}: { }: {
item: ItineraryItem; item: ItineraryItem;
badge: string; badge: string;
@ -69,6 +62,10 @@ function DayCard({
first: boolean; first: boolean;
/** 업소 상호명 — 출발지 표시용. 일정 데이터에는 없다(프롬프트가 정거장으로 못 넣게 막는다). */ /** 업소 상호명 — 출발지 표시용. 일정 데이터에는 없다(프롬프트가 정거장으로 못 넣게 막는다). */
placeName: string; placeName: string;
/** 같은 코스의 전날에서 이어지는 카드다 — 왼쪽 테두리를 지우고 앞 카드에 붙인다. */
continuesFrom?: boolean;
/** 같은 코스의 다음날로 이어진다 — 오른쪽 테두리를 점선으로 바꾼다. */
continuesTo?: boolean;
}) { }) {
const day = planDay({name: item.name, startTime, stops}); const day = planDay({name: item.name, startTime, stops});
/* . 2.5 , /* . 2.5 ,
@ -89,8 +86,14 @@ function DayCard({
* , . * , .
*/ */
<article <article
className="w4-paper w-[320px] shrink-0 snap-center border" className={`w4-paper w-[320px] shrink-0 snap-center border-y ${
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}} continuesFrom ? '-ml-4 border-l-0' : 'border-l'
} ${continuesTo ? '' : 'border-r'}`}
style={{
backgroundColor: ITEM_CARD,
borderColor: ITEM_BORDER,
...(continuesTo ? {borderRight: `2px dashed ${ITEM_BORDER}`} : null),
}}
> >
<div <div
className="flex items-center justify-between gap-2 border-b px-4 py-2.5" className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
@ -344,9 +347,12 @@ function DurationRail({
)} )}
<Rail label={`${tab} 일정`} onSelect={setSlide} onReady={onReady}> <Rail label={`${tab} 일정`} onSelect={setSlide} onReady={onReady}>
{courses.flatMap(({item, start}) => {courses.flatMap(({item, start}) => {
daysOf(item).flatMap((day, dayIndex) => { const days = daysOf(item);
const card = ( // ★ 같은 코스의 날짜 카드는 붙여서 한 판처럼 잇는다 — 앞 카드 오른쪽 테두리를
// 점선으로, 뒷 카드 왼쪽 테두리는 지운다(대표: "같은 일정이면 띄어놓지 말라고").
// 코스가 갈리는 경계에는 이 처리가 없어 레일 위에서 그대로 구분된다.
return days.map((day, dayIndex) => (
<DayCard <DayCard
key={`${item.name}-${start}-${dayIndex}`} key={`${item.name}-${start}-${dayIndex}`}
item={item} item={item}
@ -355,14 +361,11 @@ function DurationRail({
stops={day.stops} stops={day.stops}
first={dayIndex === 0} first={dayIndex === 0}
placeName={placeName} placeName={placeName}
continuesFrom={dayIndex > 0}
continuesTo={dayIndex < days.length - 1}
/> />
); ));
// ★ 같은 코스의 날짜 카드는 점선으로 잇는다(대표: "같은 일정은 붙어있게, 점선으로") — })}
// 코스가 갈리는 경계에는 이 이음줄이 없어 레일 위에서 그대로 구분된다.
if (dayIndex === 0) return [card];
return [<DayConnector key={`${item.name}-${start}-c${dayIndex}`} />, card];
}),
)}
</Rail> </Rail>
</> </>
); );

View File

@ -19,7 +19,7 @@ import {PeopleSection} from './PeopleSection';
import {ChronicleSection} from './ChronicleSection'; import {ChronicleSection} from './ChronicleSection';
import {ReadingSection} from './ReadingSection'; import {ReadingSection} from './ReadingSection';
import {PostcardSection} from './PostcardSection'; import {PostcardSection} from './PostcardSection';
import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK} from './common'; import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK, TabPanelContext} from './common';
/* /*
* (2026-09-04, : "다섯 갈래인데 4개잖아") * (2026-09-04, : "다섯 갈래인데 4개잖아")
@ -35,11 +35,11 @@ const COUNT_WORD: Record<number, string> = {
(`군산 읽기` ). */ (`군산 읽기` ). */
const tabsOf = (readingLabel: string) => const tabsOf = (readingLabel: string) =>
[ [
{id: 'songs', label: '가요 다방', Component: SongsSection}, {id: 'songs', label: '가요 다방', Component: SongsSection, dark: false},
{id: 'people', label: '인물 열전', Component: PeopleSection}, {id: 'people', label: '인물 열전', Component: PeopleSection, dark: true},
{id: 'chronicle', label: '시간의 골목', Component: ChronicleSection}, {id: 'chronicle', label: '시간의 골목', Component: ChronicleSection, dark: false},
{id: 'reading', label: readingLabel, Component: ReadingSection}, {id: 'reading', label: readingLabel, Component: ReadingSection, dark: false},
{id: 'postcard', label: '오늘의 엽서', Component: PostcardSection}, {id: 'postcard', label: '오늘의 엽서', Component: PostcardSection, dark: false},
] as const; ] as const;
export function StorySection() { export function StorySection() {
@ -66,12 +66,13 @@ export function StorySection() {
color: ITEM_INK, color: ITEM_INK,
paddingTop: 'var(--section-space)', paddingTop: 'var(--section-space)',
/* /*
* (2026-09-04, : "탭이랑 밑의 섹션 간격") * (2026-09-04, : "탭이랑 밑의 섹션 간격") ,
* 0 "다음 덩이가 제 여백을 들고 온다" . * ( ) . paddingBlock ,
* ( ) , * . ( )
* . , . * --tpl-surface
* (2026-09-18 : "간격 너무 넓음" ).
*/ */
paddingBottom: 'calc(var(--section-space) * 0.55)', paddingBottom: tabs[active]?.dark ? 'calc(var(--section-space) * 0.55)' : 0,
}} }}
> >
<div className="shell"> <div className="shell">
@ -108,11 +109,13 @@ export function StorySection() {
</div> </div>
</section> </section>
<TabPanelContext.Provider value={true}>
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<div key={tab.id} hidden={index !== active}> <div key={tab.id} hidden={index !== active}>
<tab.Component /> <tab.Component />
</div> </div>
))} ))}
</TabPanelContext.Provider>
</> </>
); );
} }

View File

@ -107,7 +107,7 @@ export function VideoSection() {
*/ */
className={ className={
many many
? 'flex snap-x snap-mandatory gap-4 overflow-x-auto [touch-action:pan-y_pinch-zoom] pb-2 [scrollbar-width:none] lg:grid lg:grid-cols-3 lg:gap-4 lg:snap-none lg:overflow-visible lg:pb-0 [&::-webkit-scrollbar]:hidden' ? 'flex snap-x snap-mandatory gap-4 overflow-x-auto [touch-action:pan-y_pinch-zoom] pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
: 'grid grid-cols-1 gap-4 sm:max-w-md sm:mx-auto' : 'grid grid-cols-1 gap-4 sm:max-w-md sm:mx-auto'
} }
> >
@ -119,7 +119,7 @@ export function VideoSection() {
<li <li
key={`${item.url}-${index}`} key={`${item.url}-${index}`}
/* 다음 편이 걸쳐 보여야 "옆으로 더 있다"가 읽힌다. 넓은 화면은 격자 칸이 폭을 정한다. */ /* 다음 편이 걸쳐 보여야 "옆으로 더 있다"가 읽힌다. 넓은 화면은 격자 칸이 폭을 정한다. */
className={many ? 'shrink-0 basis-[86%] snap-start sm:basis-[56%] lg:basis-auto' : ''} className={many ? 'shrink-0 basis-[46%] snap-start sm:basis-[30%] lg:basis-[200px]' : ''}
> >
<figure className="space-y-2"> <figure className="space-y-2">
<div <div
@ -223,7 +223,7 @@ function ScrollNav({
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
aria-label={dir === 'prev' ? '이전 영상' : '다음 영상'} aria-label={dir === 'prev' ? '이전 영상' : '다음 영상'}
className={`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:pointer-events-none disabled:opacity-0 lg:hidden ${ className={`tap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur transition hover:bg-black/75 disabled:pointer-events-none disabled:opacity-0 ${
dir === 'prev' ? 'left-1' : 'right-1' dir === 'prev' ? 'left-1' : 'right-1'
}`} }`}
> >

View File

@ -8,10 +8,13 @@
* '지금 한 곡' HTML . * '지금 한 곡' HTML .
* AI· , . * AI· , .
*/ */
import {createContext, useContext} from 'react';
import type {ReactNode} from 'react'; import type {ReactNode} from 'react';
import type {DataSource, DataVerified} from '@o2o/shared'; import type {DataSource, DataVerified} from '@o2o/shared';
import {Carousel} from '@site/lib/ui'; import {Carousel} from '@site/lib/ui';
import {useLayout} from '@site/lib/layout'; import {useLayout} from '@site/lib/layout';
export const TabPanelContext = createContext(false);
// 안별 제목은 `lib/ui/Section` 과 **같은 파일**을 직접 가리킨다. // 안별 제목은 `lib/ui/Section` 과 **같은 파일**을 직접 가리킨다.
// `@/sections` 배럴로 돌아가면 이 파일이 그 배럴 안에 있어 순환이다. // `@/sections` 배럴로 돌아가면 이 파일이 그 배럴 안에 있어 순환이다.
import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead'; import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead';
@ -89,6 +92,7 @@ export function ItemSection({
dark?: boolean; dark?: boolean;
}) { }) {
const layout = useLayout(); const layout = useLayout();
const inTabPanel = useContext(TabPanelContext);
const Head = const Head =
layout === 'reservation' layout === 'reservation'
? ReservationHead ? ReservationHead
@ -129,10 +133,10 @@ export function ItemSection({
{Head ? ( {Head ? (
<Head id={id} title={name} lead={subtitle} aside={linkNode} /> <Head id={id} title={name} lead={subtitle} aside={linkNode} />
) : ( ) : (
<header className="mb-8 sm:mb-10"> <header className={inTabPanel ? '' : 'mb-8 sm:mb-10'}>
{/* 제목과 바깥 링크를 한 줄에. 좁은 화면에서는 링크가 아래로 떨어진다. */} {/* 제목과 바깥 링크를 한 줄에. 좁은 화면에서는 링크가 아래로 떨어진다. */}
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2"> <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
<h2 id={`${id}-heading`} className="h2"> <h2 id={`${id}-heading`} className={inTabPanel ? 'sr-only' : 'h2'}>
{name} {name}
</h2> </h2>
{linkNode} {linkNode}

View File

@ -29,7 +29,6 @@ it('renders only the reservation notice from guides, before booking actions, esc
expect(html).toContain('&lt;script&gt;'); expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<script>'); expect(html).not.toContain('<script>');
expect(html).not.toContain('모든 항목이 사업자 확인'); expect(html).not.toContain('모든 항목이 사업자 확인');
expect(html.indexOf('반려동물 입실금지')).toBeLessThan(html.indexOf('예약은 아래로'));
}); });
it('does not render or embed guides from unconfirmed links', () => { it('does not render or embed guides from unconfirmed links', () => {
@ -66,5 +65,4 @@ it('keeps structured rows and restrictions without the original disclosure or so
expect(html).not.toContain('NOL 안내 원문'); expect(html).not.toContain('NOL 안내 원문');
expect(html).not.toContain('체크인 15:00 체크아웃 11:00'); expect(html).not.toContain('체크인 15:00 체크아웃 11:00');
expect(html).toContain('반려동물 입실금지'); expect(html).toContain('반려동물 입실금지');
expect(html).toContain('예약은 아래로');
}); });