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: '요금'},
];
export function MarketingShell({children}: {children: ReactNode}) {
export function MarketingShell({
children,
showAuthCta = true,
}: {
children: ReactNode;
showAuthCta?: boolean;
}) {
const user = useAuthStore((s) => s.user);
return (
@ -52,7 +58,7 @@ export function MarketingShell({children}: {children: ReactNode}) {
. .
*/}
{user ? (
{showAuthCta && (user ? (
<Link
to="/sites"
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>
)}
))}
</div>
</div>
</header>

View File

@ -1,5 +1,5 @@
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 {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid';
@ -74,6 +74,8 @@ const HEADLINES = [
export function LandingPage() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [searchParams] = useSearchParams();
const buildMode = searchParams.get('build') === '1';
const submit = (event: FormEvent) => {
event.preventDefault();
@ -84,7 +86,7 @@ export function LandingPage() {
};
return (
<MarketingShell>
<MarketingShell showAuthCta={buildMode}>
{/* ── 상단 ─────────────────────────────────────── */}
<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">
@ -101,56 +103,60 @@ export function LandingPage() {
</span>
</h1>
<p className="mx-auto mt-6 text-base text-muted-foreground sm:text-lg">
</p>
{buildMode && (
<p className="mx-auto mt-6 text-base text-muted-foreground sm:text-lg">
</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">
<ShowcasePeeks />
</div>
<form
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" />
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
</span>
{/* 업종부터 고르고 싶은 사람을 위한 갈래. 위저드의 업종 단계로 바로 보낸다. */}
<Link
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>
{buildMode && (
<form
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" />
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
</span>
{/* 업종부터 고르고 싶은 사람을 위한 갈래. 위저드의 업종 단계로 바로 보낸다. */}
<Link
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>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="가게 이름을 입력하세요"
aria-label="가게 이름"
className="mt-4 w-full border-0 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground sm:text-lg"
/>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="가게 이름을 입력하세요"
aria-label="가게 이름"
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">
<p className="truncate text-xs text-muted-foreground">
· · · ·
</p>
<button
type="submit"
aria-label="확인하기"
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 />
</button>
</div>
</form>
<div className="mt-8 flex items-center justify-between gap-3">
<p className="truncate text-xs text-muted-foreground">
· · · ·
</p>
<button
type="submit"
aria-label="확인하기"
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 />
</button>
</div>
</form>
)}
</div>
</div>
</section>

View File

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

View File

@ -13,7 +13,7 @@
* `data-slider="on"` .
* ** 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 {AUTOPLAY_MS, useRailAutoplay} from './use-rail-autoplay';
import {ChevronLeft, ChevronRight} from 'lucide-react';
@ -52,6 +52,14 @@ interface CarouselProps {
* flex `gap`
* . .
* 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;
/**
@ -66,6 +74,8 @@ interface CarouselProps {
className?: string;
}
/** `loop` 일 때만 값이 선다(간격을 rem 값으로) — `CarouselSlide` 가 자기 margin 으로 대신 낸다. */
const SlideGapContext = createContext<number | null>(null);
export function Carousel({
label,
@ -212,8 +222,8 @@ export function Carousel({
}
}}
>
<div className="slider-track" style={{gap: `${gap}rem`}} id={id}>
{children}
<div className="slider-track" style={{gap: loop ? 0 : `${gap}rem`}} id={id}>
<SlideGapContext.Provider value={loop ? gap : null}>{children}</SlideGapContext.Provider>
</div>
</div>
@ -264,8 +274,13 @@ export function Carousel({
* "옆으로 더 있다" ** .
*/
export function CarouselSlide({basis, children}: {basis: string; children: ReactNode}) {
const loopGap = useContext(SlideGapContext);
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}
</div>
);

View File

@ -1,6 +1,7 @@
import {useEffect} from 'react';
import {useEffect, useRef} from 'react';
import type {ReactNode} from 'react';
import {X} from 'lucide-react';
import {useScrollLock} from './use-scroll-lock';
/**
* () · () .
@ -13,29 +14,32 @@ export function Modal({
open,
onClose,
label,
title,
children,
wide,
}: {
open: boolean;
onClose: () => void;
label: string;
/** 닫기 버튼과 한 줄에 놓일 제목. 없으면 닫기 버튼만 뜬다. */
title?: ReactNode;
children: ReactNode;
/** 안이 2열(정보+예약)로 갈리는 경우처럼 lg 폭이 필요할 때. */
wide?: boolean;
}) {
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useScrollLock(open);
useEffect(() => {
if (!open) return;
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);
return () => {
document.body.style.overflow = previous;
window.removeEventListener('keydown', onKey);
};
}, [open, onClose]);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
if (!open) return null;
@ -51,24 +55,27 @@ export function Modal({
role="dialog"
aria-modal="true"
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'
}`}
style={{backgroundColor: 'var(--color-surface)'}}
>
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="tap absolute right-3 top-3 z-10 flex size-10 items-center justify-center rounded-full shadow-md"
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}
<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
type="button"
onClick={onClose}
aria-label="닫기"
className="tap ml-auto flex size-10 shrink-0 items-center justify-center rounded-full shadow-md"
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>
</div>
<div className="overflow-y-auto p-5 pb-8">{children}</div>
</div>
</div>
);

View File

@ -3,3 +3,4 @@ export {Carousel, CarouselSlide} from './Carousel';
export {Card, Chip, Field, ActionLink} from './primitives';
export {ShowMore} from './ShowMore';
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 type {PostEntry} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {formatKoreanDate} from '@site/lib/format';
import {Section} from '@site/lib/ui';
import {isoDate} from '@site/lib/format';
import {Carousel, CarouselSlide, Modal, Section} from '@site/lib/ui';
/**
* . 기획: docs/MINI_BLOG.md
*
* HTML .
* 2 .
* ( ). .
* HTML .
* (`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() {
const payload = useSite();
const posts = payload.posts ?? [];
const [page, setPage] = useState(0);
const [openId, setOpenId] = useState<string | null>(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 (
<Section
id="blog"
tone="alt"
title={`${payload.place.name}의 기록`}
lead="사장님이 띄엄띄엄 남기는 짧은 글입니다."
title="미니 블로그"
lead="사장님이 남기는 숙소 이야기입니다."
>
<ul className="divide-line divide-y">
{/* 전부 그리고 이번 장이 아닌 것만 감춘다 — 잘라내면 구운 HTML 에 안 남는다. */}
{posts.map((post, index) => (
<li
key={post.postId}
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>
<Carousel label={`${payload.place.name} 미니 블로그`} align="start" gap={0.875} loop>
{posts.map((post) => (
<CarouselSlide key={post.postId} basis="basis-[225px]">
<PostCard post={post} onOpen={() => setOpenId(post.postId)} />
</CarouselSlide>
))}
</ul>
</Carousel>
{pages > 1 && (
<nav aria-label="글 페이지" className="mt-5 flex flex-wrap justify-center gap-1.5">
{Array.from({length: pages}, (_, index) => (
<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 open={openPost != null} onClose={() => setOpenId(null)} label={`미니 블로그 · ${openLabel}`} title={openLabel}>
{openPost && (
<p className="whitespace-pre-line break-keep text-[length:var(--fs-body)] leading-relaxed">{openPost.body}</p>
)}
</Modal>
</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}
maxLength={1000}
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)'}}
/>
</div>
@ -163,7 +163,7 @@ function Field({id, label, name, placeholder, type = 'text', required, hint}: {
required={required}
maxLength={60}
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)'}}
/>
{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 {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 type {InfoRow} from '@site/lib/derive';
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 TWO_LINE_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() {
const payload = useSite();
const rows = essentialRows(payload);
@ -53,7 +65,7 @@ export function EssentialInfoSection() {
const guides = payload.links.filter((link) => link.confirmed &&
[link.stayGuide?.policy, link.stayGuide?.service, link.stayGuide?.reservation].some((text) => text?.trim()));
const structured = guides.flatMap((link) => link.stayGuide?.fields ?? []);
const mergedRows = [...rows];
const mergedRows = mergeCheckInOut([...rows]);
const seen = new Set(rows.map((row) => row.key));
for (const field of structured) {
// 직접 입력한 노출값을 우선한다. 같은 항목을 출처마다 반복하지 않는다.
@ -88,7 +100,7 @@ export function EssentialInfoSection() {
/* (2026-09-03, )
"이용 및 예약 안내" "예약 안내"
. . */
title="이용안내 및 예약"
title="이용안내"
lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다."
>
<div className="space-y-8">
@ -102,7 +114,6 @@ export function EssentialInfoSection() {
{notices.map((text) => (
<ReservationNotice key={text.slice(0, 32)} text={text} />
))}
<BookingRow />
</div>
</Section>
);
@ -143,7 +154,7 @@ function Rows({title, rows, emphasis, children}: {
{rows.length > 0 && (
<dl className="divide-line divide-y">
{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 (
<div
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>
<Modal open={open != null} onClose={() => setOpen(null)} label={open?.name ?? '축제'}>
<Modal open={open != null} onClose={() => setOpen(null)} label={open?.name ?? '축제'} title={open?.name}>
{open && (
<div className="flex flex-col gap-4">
{open.imageUrl && (
@ -220,7 +220,6 @@ function FestivalRail({
className="aspect-4/3 w-full rounded-lg object-cover"
/>
)}
<h3 className="h3">{open.name}</h3>
{open.period && (
<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 {useSite} from '@site/lib/site-context';
import {galleryImages} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
import {AUTOPLAY_MS, scrollRailNext, useRailAutoplay} from '@site/lib/ui/use-rail-autoplay';
import {Carousel, CarouselSlide, Section, useScrollLock} from '@site/lib/ui';
/**
* .
@ -19,45 +18,6 @@ export function GallerySection() {
const variantId = setting?.variantId ?? 'photos.grid';
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 step = useCallback(
(delta: number) =>
@ -69,6 +29,8 @@ export function GallerySection() {
// 크게 보기 중에는 Esc 로 닫고 좌우 키로 넘긴다. 뒤 배경은 스크롤을 잠근다 —
// 잠그지 않으면 모달 위에서 휠을 굴렸을 때 뒤 페이지가 밀린다.
useScrollLock(openIndex !== null);
useEffect(() => {
if (openIndex === null) return;
const onKey = (event: KeyboardEvent) => {
@ -76,13 +38,8 @@ export function GallerySection() {
if (event.key === 'ArrowLeft') step(-1);
if (event.key === 'ArrowRight') step(1);
};
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);
};
return () => window.removeEventListener('keydown', onKey);
}, [openIndex, close, step]);
if (images.length === 0) return null;
@ -91,29 +48,26 @@ export function GallerySection() {
<Section id="gallery" title={setting?.name || '공간 갤러리'}>
{/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */}
{variantId === 'photos.carousel' ? (
/*
* .
* , .
* CSS .
* matchMedia .
* .
*
* (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"
>
<>
{/*
. CSS
( ) . */}
<div className="lg:hidden">
<Carousel label="공간 갤러리" align="center" gap={0} arrows="overlay">
{images.map((image, index) => (
<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} className="w-full shrink-0 snap-center lg:w-auto">
<li key={image.mediaId}>
<GalleryImage
image={image}
onOpen={() => setOpenIndex(index)}
@ -122,18 +76,7 @@ export function GallerySection() {
</li>
))}
</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' ? (
<ul className="columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3">
{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}) {
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;

View File

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

View File

@ -1,6 +1,10 @@
import {useState} from 'react';
import {MapPin, MessageCircle, Phone} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
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 booking = links[0];
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;
return (
<>
<nav
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"
@ -61,18 +68,36 @@ export function MobileTabBar() {
)}
{booking && (
<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>
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
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>
{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 {useSite} from '@site/lib/site-context';
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]);
useEffect(() => {
if (!open) return;
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);
};
if (open) openedAt.current = Date.now();
}, [open]);
async function submit(event: React.FormEvent<HTMLFormElement>) {
@ -102,7 +91,7 @@ export function ReviewSection() {
id="reviews"
tone="alt"
title="다녀오신 이야기"
lead="점수 대신 문장으로 남겨 주세요."
lead="다녀가신 분들이 남긴 이야기입니다."
>
{reviews.length > 0 && (
<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>
{open && (
<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)'}}
>
<Modal open={open} onClose={() => setOpen(false)} label="후기 남기기" title="후기 남기기">
<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">
<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)'}}
/>
<p className="text-muted text-[length:var(--fs-xs)] tabular-nums">
{text.length} / {MAX_LEN} ·
{text.length} / {MAX_LEN}
</p>
</div>
@ -245,21 +219,8 @@ export function ReviewSection() {
{sending ? '보내는 중…' : '후기 보내기'}
</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>
</div>
</div>
)}
</Modal>
</Section>
);
}

View File

@ -1,8 +1,12 @@
import {useState} from 'react';
import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
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 주석 참고).
const link = primaryChannelLink(payload);
const address = place.roadAddress ?? place.address;
const booking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const [bookingOpen, setBookingOpen] = useState(false);
return (
<footer
@ -62,15 +68,25 @@ export function SiteFooter() {
<h2 className="label mb-3 !text-current opacity-100"> </h2>
<ul className="flex flex-wrap gap-2">
<li>
<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>{channelLabel(link)}</span>
<ExternalLink className="size-3.5" />
</a>
{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
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>
</ul>
</nav>
@ -129,6 +145,12 @@ export function SiteFooter() {
Web4Ai로 .
</p>
</div>
{booking && (
<Modal open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" title="예약 요청" wide>
<StayBookingDemo />
</Modal>
)}
</footer>
);
}

View File

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

View File

@ -33,6 +33,7 @@ export function UnitsSection() {
const spec = unitSpec(payload);
const booking = stayBookingView(payload);
const [openUnit, setOpenUnit] = useState<string | null>(null);
const [bookingOpen, setBookingOpen] = useState(false);
// ★ 격자는 '고르는 화면', 밴드는 '보여 주는 화면'이다. 독채 두 동에는 밴드가 맞다.
const layout = useLayout();
if (layout === 'reservation') return <ReservationRooms />;
@ -133,12 +134,9 @@ export function UnitsSection() {
open={openUnit === unit.unitId}
onClose={() => setOpenUnit(null)}
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">
<h3 className="h3">{unit.name}</h3>
<div className="flex flex-col gap-5">
{offer?.baseRateText && (
<p className="text-[length:var(--fs-lead)] font-bold" style={{color: 'var(--color-brand)'}}>
{offer.baseRateText}
@ -176,45 +174,51 @@ export function UnitsSection() {
))}
</dl>
)}
</div>
{booking && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<p className="text-[length:var(--fs-sm)] font-bold"></p>
{booking.phone && (
<a
href={`tel:${booking.phone}`}
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)'}}
>
<Phone className="size-4" />
<span> {booking.phone}</span>
</a>
)}
{booking.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
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"
>
<span>{bookingCtaLabel(link)}</span>
<ArrowUpRight className="size-4 shrink-0" />
</a>
))}
</div>
{/* +
(PRODUCT.md 6), StayBookingDemo . */}
<StayBookingDemo />
<div className="border-line flex flex-col gap-2 border-t pt-4">
<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 && (
<a
href={`tel:${booking.phone}`}
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"
>
<Phone className="size-4" />
<span> {booking.phone}</span>
</a>
)}
{booking.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
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"
>
<span>{bookingCtaLabel(link)}</span>
<ArrowUpRight className="size-4 shrink-0" />
</a>
))}
</div>
)}
</div>
</Modal>
);
})}
{booking && (
<Modal open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" title="예약 요청" wide>
<StayBookingDemo />
</Modal>
)}
</Section>
);
}

View File

@ -83,7 +83,7 @@ export function WeatherSection() {
</span>
</p>
{observed && (
<span className="text-muted text-[length:var(--fs-xs)] tabular-nums">
<span className="text-[length:var(--fs-sm)] tabular-nums">
{observed}
{weather.stale && ' · 최근 관측값'}
</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"
>
<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
>
{band}

View File

@ -30,7 +30,15 @@ it('구운 HTML 에 글이 전부 들어간다 — 크롤러는 2페이지를
for (let i = 0; i < 23; i += 1) expect(html).toContain(`${i}번째 글입니다`);
});
it('열 개를 넘으면 페이지 번호가 선다', () => {
expect(render(withPosts(23))).toContain('aria-label="글 페이지"');
expect(render(withPosts(4))).not.toContain('aria-label="글 페이지"');
it('글 개수와 무관하게 카로셀 하나로 뜬다 — 페이지를 끊지 않는다', () => {
expect(render(withPosts(23))).toContain('slider-viewport');
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 {ShowMore} from '@site/lib/ui';
import {ShowMore, useScrollLock} from '@site/lib/ui';
import {X} from 'lucide-react';
import type {EventItem} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
@ -90,18 +90,15 @@ export function EventSection() {
const close = useCallback(() => setOpen(null), []);
// 창이 열려 있는 동안 Esc 로 닫고 뒤 배경 스크롤을 잠근다 — 안 잠그면 창 위에서 뒤가 밀린다.
useScrollLock(open !== null);
useEffect(() => {
if (open === null) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') close();
};
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);
};
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
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 ?? []}];
}
/** 같은 코스의 날짜 카드 사이 점선 이음줄. 레일 안 다른 카드와 같은 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({
item,
badge,
@ -61,6 +52,8 @@ function DayCard({
stops,
first,
placeName,
continuesFrom,
continuesTo,
}: {
item: ItineraryItem;
badge: string;
@ -69,6 +62,10 @@ function DayCard({
first: boolean;
/** 업소 상호명 — 출발지 표시용. 일정 데이터에는 없다(프롬프트가 정거장으로 못 넣게 막는다). */
placeName: string;
/** 같은 코스의 전날에서 이어지는 카드다 — 왼쪽 테두리를 지우고 앞 카드에 붙인다. */
continuesFrom?: boolean;
/** 같은 코스의 다음날로 이어진다 — 오른쪽 테두리를 점선으로 바꾼다. */
continuesTo?: boolean;
}) {
const day = planDay({name: item.name, startTime, stops});
/* . 2.5 ,
@ -89,8 +86,14 @@ function DayCard({
* , .
*/
<article
className="w4-paper w-[320px] shrink-0 snap-center border"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
className={`w4-paper w-[320px] shrink-0 snap-center border-y ${
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
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}>
{courses.flatMap(({item, start}) =>
daysOf(item).flatMap((day, dayIndex) => {
const card = (
<DayCard
key={`${item.name}-${start}-${dayIndex}`}
item={item}
badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`}
startTime={day.startTime}
stops={day.stops}
first={dayIndex === 0}
placeName={placeName}
/>
);
// ★ 같은 코스의 날짜 카드는 점선으로 잇는다(대표: "같은 일정은 붙어있게, 점선으로") —
// 코스가 갈리는 경계에는 이 이음줄이 없어 레일 위에서 그대로 구분된다.
if (dayIndex === 0) return [card];
return [<DayConnector key={`${item.name}-${start}-c${dayIndex}`} />, card];
}),
)}
{courses.flatMap(({item, start}) => {
const days = daysOf(item);
// ★ 같은 코스의 날짜 카드는 붙여서 한 판처럼 잇는다 — 앞 카드 오른쪽 테두리를
// 점선으로, 뒷 카드 왼쪽 테두리는 지운다(대표: "같은 일정이면 띄어놓지 말라고").
// 코스가 갈리는 경계에는 이 처리가 없어 레일 위에서 그대로 구분된다.
return days.map((day, dayIndex) => (
<DayCard
key={`${item.name}-${start}-${dayIndex}`}
item={item}
badge={day.label ?? item.duration ?? `${dayIndex + 1}일차`}
startTime={day.startTime}
stops={day.stops}
first={dayIndex === 0}
placeName={placeName}
continuesFrom={dayIndex > 0}
continuesTo={dayIndex < days.length - 1}
/>
));
})}
</Rail>
</>
);

View File

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

View File

@ -107,7 +107,7 @@ export function VideoSection() {
*/
className={
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'
}
>
@ -119,7 +119,7 @@ export function VideoSection() {
<li
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">
<div
@ -223,7 +223,7 @@ function ScrollNav({
onClick={onClick}
disabled={disabled}
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'
}`}
>

View File

@ -8,10 +8,13 @@
* '지금 한 곡' HTML .
* AI· , .
*/
import {createContext, useContext} from 'react';
import type {ReactNode} from 'react';
import type {DataSource, DataVerified} from '@o2o/shared';
import {Carousel} from '@site/lib/ui';
import {useLayout} from '@site/lib/layout';
export const TabPanelContext = createContext(false);
// 안별 제목은 `lib/ui/Section` 과 **같은 파일**을 직접 가리킨다.
// `@/sections` 배럴로 돌아가면 이 파일이 그 배럴 안에 있어 순환이다.
import {SectionHead as ReservationHead} from '@site/layouts/reservation/SectionHead';
@ -89,6 +92,7 @@ export function ItemSection({
dark?: boolean;
}) {
const layout = useLayout();
const inTabPanel = useContext(TabPanelContext);
const Head =
layout === 'reservation'
? ReservationHead
@ -129,10 +133,10 @@ export function ItemSection({
{Head ? (
<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">
<h2 id={`${id}-heading`} className="h2">
<h2 id={`${id}-heading`} className={inTabPanel ? 'sr-only' : 'h2'}>
{name}
</h2>
{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).not.toContain('<script>');
expect(html).not.toContain('모든 항목이 사업자 확인');
expect(html.indexOf('반려동물 입실금지')).toBeLessThan(html.indexOf('예약은 아래로'));
});
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('체크인 15:00 체크아웃 11:00');
expect(html).toContain('반려동물 입실금지');
expect(html).toContain('예약은 아래로');
});