This commit is contained in:
김성경 2026-09-22 08:53:53 +09:00
commit 81a61b50f5
26 changed files with 480 additions and 447 deletions

View File

@ -17,7 +17,13 @@ const NAV = [
{to: '/pricing', label: '요금'}, {to: '/pricing', label: '요금'},
]; ];
export function MarketingShell({children}: {children: ReactNode}) { export function MarketingShell({
children,
showAuthCta = true,
}: {
children: ReactNode;
showAuthCta?: boolean;
}) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
return ( return (
@ -52,7 +58,7 @@ export function MarketingShell({children}: {children: ReactNode}) {
히어로의 큰 입력 카드가 이미 그 자리다 — 시작하는 문이 한 화면에 둘이면 히어로의 큰 입력 카드가 이미 그 자리다 — 시작하는 문이 한 화면에 둘이면
어느 쪽이 진짜인지 고르게 만든다. 헤더는 로그인만 받는다. 어느 쪽이 진짜인지 고르게 만든다. 헤더는 로그인만 받는다.
*/} */}
{user ? ( {showAuthCta && (user ? (
<Link <Link
to="/sites" to="/sites"
className="rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90" className="rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
@ -66,7 +72,7 @@ export function MarketingShell({children}: {children: ReactNode}) {
> >
로그인 로그인
</Link> </Link>
)} ))}
</div> </div>
</div> </div>
</header> </header>

View File

@ -1,5 +1,5 @@
import {useState, type FormEvent} from 'react'; import {useState, type FormEvent} from 'react';
import {Link, useNavigate} from 'react-router'; import {Link, useNavigate, useSearchParams} from 'react-router';
import {ArrowRight, ArrowUp, Check} from 'lucide-react'; import {ArrowRight, ArrowUp, Check} from 'lucide-react';
import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell'; import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid'; import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid';
@ -74,6 +74,8 @@ const HEADLINES = [
export function LandingPage() { export function LandingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [searchParams] = useSearchParams();
const buildMode = searchParams.get('build') === '1';
const submit = (event: FormEvent) => { const submit = (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
@ -84,7 +86,7 @@ export function LandingPage() {
}; };
return ( return (
<MarketingShell> <MarketingShell showAuthCta={buildMode}>
{/* ── 상단 ─────────────────────────────────────── */} {/* ── 상단 ─────────────────────────────────────── */}
<section className="relative flex min-h-[calc(100dvh-4rem)] items-center overflow-hidden border-b border-border"> <section className="relative flex min-h-[calc(100dvh-4rem)] items-center overflow-hidden border-b border-border">
<div className="mx-auto w-full max-w-6xl px-5 text-center"> <div className="mx-auto w-full max-w-6xl px-5 text-center">
@ -101,56 +103,60 @@ export function LandingPage() {
</span> </span>
홈페이지 홈페이지
</h1> </h1>
<p className="mx-auto mt-6 text-base text-muted-foreground sm:text-lg"> {buildMode && (
가게 이름 한 줄로 시작해 보세요 <p className="mx-auto mt-6 text-base text-muted-foreground sm:text-lg">
</p> 가게 이름 한 줄로 시작해 보세요
</p>
)}
{/* ★ 입력 카드와 발행 사이트 띠를 **같은 밴드**에 겹친다(아임웹과 같은 구조). {/* ★ 입력 카드와 발행 사이트 띠를 **같은 밴드**에 겹친다(아임웹과 같은 구조).
띠를 카드 아래 따로 두면 첫 화면이 세로로 길어지고, 카드가 뜬금없이 혼자 뜬다. */} 띠를 카드 아래 따로 두면 첫 화면이 세로로 길어지고, 카드가 뜬금없이 혼자 뜬다. */}
<div className="relative mt-14"> <div className={`relative mt-14 ${buildMode ? '' : 'min-h-52'}`}>
<div className="pointer-events-none absolute top-1/2 left-1/2 w-screen -translate-x-1/2 -translate-y-1/2"> <div className="pointer-events-none absolute top-1/2 left-1/2 w-screen -translate-x-1/2 -translate-y-1/2">
<ShowcasePeeks /> <ShowcasePeeks />
</div> </div>
<form {buildMode && (
onSubmit={submit} <form
className="relative z-10 mx-auto max-w-2xl rounded-2xl border border-border bg-card p-4 text-left shadow-[0_18px_50px_-20px_rgb(20_22_34/0.35)]" onSubmit={submit}
> className="relative z-10 mx-auto max-w-2xl rounded-2xl border border-border bg-card p-4 text-left shadow-[0_18px_50px_-20px_rgb(20_22_34/0.35)]"
<div className="flex items-center gap-2"> >
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-4 w-auto" /> <div className="flex items-center gap-2">
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"> <img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-4 w-auto" />
가게 이름으로 시작 <span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
</span> 가게 이름으로 시작
{/* 업종부터 고르고 싶은 사람을 위한 갈래. 위저드의 업종 단계로 바로 보낸다. */} </span>
<Link {/* 업종부터 고르고 싶은 사람을 위한 갈래. 위저드의 업종 단계로 바로 보낸다. */}
to="/builder?new=1&step=industry" <Link
className="ml-auto rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-foreground/25 hover:text-foreground" to="/builder?new=1&step=industry"
> className="ml-auto rounded-full border border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-foreground/25 hover:text-foreground"
업종부터 고르기 >
</Link> 업종부터 고르기
</div> </Link>
</div>
<input <input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="가게 이름을 입력하세요" placeholder="가게 이름을 입력하세요"
aria-label="가게 이름" aria-label="가게 이름"
className="mt-4 w-full border-0 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-lg" className="mt-4 w-full border-0 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-lg"
/> />
<div className="mt-8 flex items-center justify-between gap-3"> <div className="mt-8 flex items-center justify-between gap-3">
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
숙박 · 카페 · 음식점 · 피부과 · 성형외과 숙박 · 카페 · 음식점 · 피부과 · 성형외과
</p> </p>
<button <button
type="submit" type="submit"
aria-label="확인하기" aria-label="확인하기"
className="grid size-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90" className="grid size-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
> >
<ArrowUp className="size-4" aria-hidden /> <ArrowUp className="size-4" aria-hidden />
</button> </button>
</div> </div>
</form> </form>
)}
</div> </div>
</div> </div>
</section> </section>

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,6 +1,7 @@
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';
import {useScrollLock} from './use-scroll-lock';
/** /**
* 바텀시트(모바일) · 가운데 카드(데스크톱) 모달 한 장. * 바텀시트(모바일) · 가운데 카드(데스크톱) 모달 한 장.
@ -13,29 +14,32 @@ 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;
useScrollLock(open);
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;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => window.removeEventListener('keydown', onKey);
document.body.style.overflow = previous; }, [open]);
window.removeEventListener('keydown', onKey);
};
}, [open, onClose]);
if (!open) return null; if (!open) return null;
@ -51,24 +55,27 @@ 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)'}}
> >
<button <div className="border-line flex shrink-0 items-center justify-between gap-3 border-b p-5 pb-4">
type="button" {title && <h3 className="h3 min-w-0 truncate">{title}</h3>}
onClick={onClose} <button
aria-label="닫기" type="button"
className="tap absolute right-3 top-3 z-10 flex size-10 items-center justify-center rounded-full shadow-md" onClick={onClose}
style={{ aria-label="닫기"
backgroundColor: 'var(--color-surface)', className="tap ml-auto flex size-10 shrink-0 items-center justify-center rounded-full shadow-md"
boxShadow: '0 1px 2px rgba(0,0,0,.18), 0 0 0 1px color-mix(in oklab, currentColor 12%, transparent)', style={{
}} backgroundColor: 'var(--color-surface)',
> boxShadow: '0 1px 2px rgba(0,0,0,.18), 0 0 0 1px color-mix(in oklab, currentColor 12%, transparent)',
<X className="size-5" /> }}
</button> >
{children} <X className="size-5" />
</button>
</div>
<div className="overflow-y-auto p-5 pb-8">{children}</div>
</div> </div>
</div> </div>
); );

View File

@ -3,3 +3,4 @@ export {Carousel, CarouselSlide} from './Carousel';
export {Card, Chip, Field, ActionLink} from './primitives'; export {Card, Chip, Field, ActionLink} from './primitives';
export {ShowMore} from './ShowMore'; export {ShowMore} from './ShowMore';
export {Modal} from './Modal'; export {Modal} from './Modal';
export {useScrollLock} from './use-scroll-lock';

View File

@ -0,0 +1,39 @@
/**
* 모달·오버레이가 열려 있는 동안 배경 스크롤을 잠근다 — **한 벌만 둔다.**
*
* ★ 왜 훅으로 뺐나 (2026-09-21, 사장님: "이거 목업 하나만 해결하면 끝이야? 훅으로 만들어놓던가")
* 같은 잠금·복귀 코드가 Modal · GallerySection · EventSection 세 곳에 따로 박혀 있었다.
* 모달 하나만 고치고 나니 나머지 둘은 그대로 남아 같은 스크롤 버그가 다시 났다 —
* 복사한 코드는 복사한 곳마다 따로 고쳐야 한다.
* ★ 복귀는 즉시 이동이어야 한다. `index.css` 의 전역 `html { scroll-behavior: smooth }` 때문에
* `window.scrollTo` 가 애니메이션으로 움직이며 "위로 튀었다 내려오는" 것처럼 보인다 —
* 이 한 번만 `scroll-behavior: auto` 로 강제한다.
*/
import {useEffect} from 'react';
export function useScrollLock(active: boolean) {
useEffect(() => {
if (!active) return;
const scrollY = window.scrollY;
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';
return () => {
body.style.position = prevPosition;
body.style.top = prevTop;
body.style.width = prevWidth;
body.style.overflow = prevOverflow;
const root = document.documentElement;
const prevScrollBehavior = root.style.scrollBehavior;
root.style.scrollBehavior = 'auto';
window.scrollTo(0, scrollY);
root.style.scrollBehavior = prevScrollBehavior;
};
}, [active]);
}

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} </Modal>
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>
)}
</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, useScrollLock} 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) =>
@ -69,6 +29,8 @@ export function GallerySection() {
// 크게 보기 중에는 Esc 로 닫고 좌우 키로 넘긴다. 뒤 배경은 스크롤을 잠근다 — // 크게 보기 중에는 Esc 로 닫고 좌우 키로 넘긴다. 뒤 배경은 스크롤을 잠근다 —
// 잠그지 않으면 모달 위에서 휠을 굴렸을 때 뒤 페이지가 밀린다. // 잠그지 않으면 모달 위에서 휠을 굴렸을 때 뒤 페이지가 밀린다.
useScrollLock(openIndex !== null);
useEffect(() => { useEffect(() => {
if (openIndex === null) return; if (openIndex === null) return;
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
@ -76,13 +38,8 @@ 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;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => window.removeEventListener('keydown', onKey);
document.body.style.overflow = previous;
window.removeEventListener('keydown', onKey);
};
}, [openIndex, close, step]); }, [openIndex, close, step]);
if (images.length === 0) return null; if (images.length === 0) return null;
@ -91,29 +48,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">
* {images.map((image, index) => (
* ★ 한 장씩 꽉 채운다 (2026-09-04, 사장님 지적: "모바일 처리가 안 돼 있다") <CarouselSlide key={image.mediaId} basis="basis-full">
* 82% 로 다음 장을 걸쳐 보이게 두었더니 모바일에서 사진이 작고, 옆으로 더 있다는 <GalleryImage
* 신호가 그 걸침 하나뿐이라 **아무도 밀지 않았다.** 네이버 플레이스가 하는 대로 image={image}
* 한 장을 폭에 꽉 채우고, 신호를 **장수 표시(n/N)와 좌우 버튼**으로 바꾼다. onOpen={() => setOpenIndex(index)}
* 좁은 화면에서 버튼은 손가락이 아니라 "더 있다"는 표시로 먼저 일한다. className="aspect-4/3 rounded-lg"
*/ />
<div className="relative"> </CarouselSlide>
<ul ))}
ref={track} </Carousel>
onScroll={syncTrack} </div>
/* 사진 모자이크는 12px 한 값이다 — 모바일 8px·데스크톱 12px 로 갈라 두면 <ul className="hidden lg:grid lg:grid-cols-4 lg:gap-3">
같은 사진 목록이 폭에 따라 다른 물건처럼 보인다(카드 레일은 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"> <li key={image.mediaId}>
<GalleryImage <GalleryImage
image={image} image={image}
onOpen={() => setOpenIndex(index)} onOpen={() => setOpenIndex(index)}
@ -122,18 +76,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 +146,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,18 +68,36 @@ export function MobileTabBar() {
)} )}
{booking && ( {booking && (
<a stayBooking ? (
href={booking.url} <button
target="_blank" type="button"
rel="noopener noreferrer" 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" 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)'}} style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
> >
{/* ★ channelLabel() 은 수집된 제목("스테이,머뭄 네이버 플레이스")을 준다 — 예약하기
390px 바에서 버튼 밖으로 넘쳤다. 버튼이 답할 건 '어디서 예약하나' 하나다. */} </button>
{bookingActionLabel(booking)} ) : (
</a> <a
href={booking.url}
target="_blank"
rel="noopener noreferrer"
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)'}}
>
{/* ★ channelLabel() 은 수집된 제목("스테이,머뭄 네이버 플레이스")을 준다 —
390px 바에서 버튼 밖으로 넘쳤다. 버튼이 답할 건 '어디서 예약하나' 하나다. */}
{bookingActionLabel(booking)}
</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>
<a {booking ? (
href={link.url} <button
target="_blank" type="button"
rel="noopener noreferrer" 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" 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>예약하기</span>
<ExternalLink className="size-3.5" /> </button>
</a> ) : (
<a
href={link.url}
target="_blank"
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"
>
<span>{bookingLabel(link)}</span>
<ExternalLink className="size-3.5" />
</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,45 +174,51 @@ 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
{booking.phone && ( type="button"
<a onClick={() => { setOpenUnit(null); setBookingOpen(true); }}
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 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)'}}
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}} >
> <span>날짜 선택하고 예약 요청하기</span>
<Phone className="size-4" /> </button>
<span>전화 예약 {booking.phone}</span> {booking.phone && (
</a> <a
)} href={`tel:${booking.phone}`}
{booking.links.map((link) => ( 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"
<a >
key={link.url} <Phone className="size-4" />
href={link.url} <span>전화 예약 {booking.phone}</span>
target="_blank" </a>
rel="noopener noreferrer" )}
className="tap border-line tpl-border flex items-center justify-between gap-2 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold" {booking.links.map((link) => (
> <a
<span>{bookingCtaLabel(link)}</span> key={link.url}
<ArrowUpRight className="size-4 shrink-0" /> href={link.url}
</a> target="_blank"
))} rel="noopener noreferrer"
</div> className="tap border-line tpl-border flex items-center justify-between gap-2 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
>
{/* 날짜 선택 + 연락처를 남기면 사장님 메일로 가는 예약 요청 — 실제 예약을 <span>{bookingCtaLabel(link)}</span>
확정하지 않는다(PRODUCT.md 6절), StayBookingDemo 머리주석 참고. */} <ArrowUpRight className="size-4 shrink-0" />
<StayBookingDemo /> </a>
))}
</div> </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

@ -18,7 +18,7 @@
* 카드가 무엇을 흉내 내는지 한눈에 보여야 목록이 아니라 물건으로 읽힌다. * 카드가 무엇을 흉내 내는지 한눈에 보여야 목록이 아니라 물건으로 읽힌다.
*/ */
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useState} from 'react';
import {ShowMore} from '@site/lib/ui'; import {ShowMore, useScrollLock} from '@site/lib/ui';
import {X} from 'lucide-react'; import {X} from 'lucide-react';
import type {EventItem} from '@o2o/shared'; import type {EventItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context'; import {useSite} from '@site/lib/site-context';
@ -90,18 +90,15 @@ export function EventSection() {
const close = useCallback(() => setOpen(null), []); const close = useCallback(() => setOpen(null), []);
// 창이 열려 있는 동안 Esc 로 닫고 뒤 배경 스크롤을 잠근다 — 안 잠그면 창 위에서 뒤가 밀린다. // 창이 열려 있는 동안 Esc 로 닫고 뒤 배경 스크롤을 잠근다 — 안 잠그면 창 위에서 뒤가 밀린다.
useScrollLock(open !== null);
useEffect(() => { useEffect(() => {
if (open === null) return; if (open === null) return;
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') close(); if (event.key === 'Escape') close();
}; };
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => window.removeEventListener('keydown', onKey);
document.body.style.overflow = previous;
window.removeEventListener('keydown', onKey);
};
}, [open, close]); }, [open, close]);
if (parsed.items.length === 0) return null; if (parsed.items.length === 0) return null;

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,25 +347,25 @@ 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 = ( // ★ 같은 코스의 날짜 카드는 붙여서 한 판처럼 잇는다 — 앞 카드 오른쪽 테두리를
<DayCard // 점선으로, 뒷 카드 왼쪽 테두리는 지운다(대표: "같은 일정이면 띄어놓지 말라고").
key={`${item.name}-${start}-${dayIndex}`} // 코스가 갈리는 경계에는 이 처리가 없어 레일 위에서 그대로 구분된다.
item={item} return days.map((day, dayIndex) => (
badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`} <DayCard
startTime={day.startTime} key={`${item.name}-${start}-${dayIndex}`}
stops={day.stops} item={item}
first={dayIndex === 0} badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`}
placeName={placeName} startTime={day.startTime}
/> stops={day.stops}
); first={dayIndex === 0}
// ★ 같은 코스의 날짜 카드는 점선으로 잇는다(대표: "같은 일정은 붙어있게, 점선으로") — placeName={placeName}
// 코스가 갈리는 경계에는 이 이음줄이 없어 레일 위에서 그대로 구분된다. continuesFrom={dayIndex > 0}
if (dayIndex === 0) return [card]; continuesTo={dayIndex < days.length - 1}
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>
{tabs.map((tab, index) => ( <TabPanelContext.Provider value={true}>
<div key={tab.id} hidden={index !== active}> {tabs.map((tab, index) => (
<tab.Component /> <div key={tab.id} hidden={index !== active}>
</div> <tab.Component />
))} </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('예약은 아래로');
}); });