diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 299c843..86f07cc 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -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; diff --git a/solution/site/src/lib/ui/Carousel.tsx b/solution/site/src/lib/ui/Carousel.tsx index b7f1642..bd14d1a 100644 --- a/solution/site/src/lib/ui/Carousel.tsx +++ b/solution/site/src/lib/ui/Carousel.tsx @@ -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(null); export function Carousel({ label, @@ -212,8 +222,8 @@ export function Carousel({ } }} > -
- {children} +
+ {children}
@@ -264,8 +274,13 @@ export function Carousel({ * "옆으로 더 있다"는 유일한 시각 신호다** — 딱 맞게 자르면 아무도 밀지 않는다. */ export function CarouselSlide({basis, children}: {basis: string; children: ReactNode}) { + const loopGap = useContext(SlideGapContext); return ( -
+
{children}
); diff --git a/solution/site/src/lib/ui/Modal.tsx b/solution/site/src/lib/ui/Modal.tsx index e5b4679..2c49db2 100644 --- a/solution/site/src/lib/ui/Modal.tsx +++ b/solution/site/src/lib/ui/Modal.tsx @@ -1,4 +1,4 @@ -import {useEffect} from 'react'; +import {useEffect, useRef} from 'react'; import type {ReactNode} from 'react'; import {X} from 'lucide-react'; @@ -13,29 +13,47 @@ 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; + 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'; + 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'; window.addEventListener('keydown', onKey); return () => { - document.body.style.overflow = previous; + body.style.position = prevPosition; + body.style.top = prevTop; + body.style.width = prevWidth; + body.style.overflow = prevOverflow; + window.scrollTo(0, scrollY); window.removeEventListener('keydown', onKey); }; - }, [open, onClose]); + }, [open]); if (!open) return null; @@ -51,24 +69,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)'}} > - - {children} +
+ {title &&

{title}

} + +
+
{children}
); diff --git a/solution/site/src/sections/BlogSection.tsx b/solution/site/src/sections/BlogSection.tsx index 4aa70ed..980f48c 100644 --- a/solution/site/src/sections/BlogSection.tsx +++ b/solution/site/src/sections/BlogSection.tsx @@ -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 = { + 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(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 (
-
    - {/* 전부 그리고 이번 장이 아닌 것만 감춘다 — 잘라내면 구운 HTML 에 안 남는다. */} - {posts.map((post, index) => ( - + + {posts.map((post) => ( + + setOpenId(post.postId)} /> + ))} -
+ - {pages > 1 && ( - - )} + setOpenId(null)} label={`미니 블로그 · ${openLabel}`} title={openLabel}> + {openPost && ( +

{openPost.body}

+ )} +
); } + +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 ( + + ); +} diff --git a/solution/site/src/sections/BookingRequestSection.tsx b/solution/site/src/sections/BookingRequestSection.tsx index a181120..39dd788 100644 --- a/solution/site/src/sections/BookingRequestSection.tsx +++ b/solution/site/src/sections/BookingRequestSection.tsx @@ -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)'}} /> @@ -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 &&

{hint}

} diff --git a/solution/site/src/sections/EssentialInfoSection.tsx b/solution/site/src/sections/EssentialInfoSection.tsx index 942ddaf..5053988 100644 --- a/solution/site/src/sections/EssentialInfoSection.tsx +++ b/solution/site/src/sections/EssentialInfoSection.tsx @@ -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="방문 전 확인이 필요한 운영 규정과 시설 안내입니다." >
@@ -102,7 +114,6 @@ export function EssentialInfoSection() { {notices.map((text) => ( ))} -
); @@ -143,7 +154,7 @@ function Rows({title, rows, emphasis, children}: { {rows.length > 0 && (
{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 (
-

- {payload.place.name} 예약은 아래로 받습니다. -

-
- {phone && ( - - - {phone} - - )} - {links.map((link) => ( - - {bookingActionLabel(link)} - - ))} -
-
- ); -} diff --git a/solution/site/src/sections/FestivalSection.tsx b/solution/site/src/sections/FestivalSection.tsx index 027645d..4ccf02a 100644 --- a/solution/site/src/sections/FestivalSection.tsx +++ b/solution/site/src/sections/FestivalSection.tsx @@ -210,7 +210,7 @@ function FestivalRail({ ))} - setOpen(null)} label={open?.name ?? '축제'}> + setOpen(null)} label={open?.name ?? '축제'} title={open?.name}> {open && (
{open.imageUrl && ( @@ -220,7 +220,6 @@ function FestivalRail({ className="aspect-4/3 w-full rounded-lg object-cover" /> )} -

{open.name}

{open.period && (

{open.period}

)} diff --git a/solution/site/src/sections/GallerySection.tsx b/solution/site/src/sections/GallerySection.tsx index 8ef403d..a27e484 100644 --- a/solution/site/src/sections/GallerySection.tsx +++ b/solution/site/src/sections/GallerySection.tsx @@ -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} from '@site/lib/ui'; /** * 사진 갤러리. @@ -19,45 +18,6 @@ export function GallerySection() { const variantId = setting?.variantId ?? 'photos.grid'; const [openIndex, setOpenIndex] = useState(null); - // 좁은 화면 캐러셀의 현재 장 · 양끝 여부. 넓은 화면은 격자라 쓰이지 않는다. - const track = useRef(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) => @@ -76,11 +36,23 @@ 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'; + 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'; window.addEventListener('keydown', onKey); return () => { - document.body.style.overflow = previous; + body.style.position = prevPosition; + body.style.top = prevTop; + body.style.width = prevWidth; + body.style.overflow = prevOverflow; + window.scrollTo(0, scrollY); window.removeEventListener('keydown', onKey); }; }, [openIndex, close, step]); @@ -91,29 +63,26 @@ export function GallerySection() { ); } diff --git a/solution/site/src/sections/SiteFooter.tsx b/solution/site/src/sections/SiteFooter.tsx index c587944..2e1fd83 100644 --- a/solution/site/src/sections/SiteFooter.tsx +++ b/solution/site/src/sections/SiteFooter.tsx @@ -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 (
공식 채널
  • - - {channelLabel(link)} - - + {booking ? ( + + ) : ( + + {bookingLabel(link)} + + + )}
@@ -129,6 +145,12 @@ export function SiteFooter() { 의 Web4Ai로 만든 사이트입니다.

+ + {booking && ( + setBookingOpen(false)} label="예약 요청" title="예약 요청" wide> + + + )} ); } diff --git a/solution/site/src/sections/SiteHeader.tsx b/solution/site/src/sections/SiteHeader.tsx index e2da5d0..804b93f 100644 --- a/solution/site/src/sections/SiteHeader.tsx +++ b/solution/site/src/sections/SiteHeader.tsx @@ -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')}, diff --git a/solution/site/src/sections/UnitsSection.tsx b/solution/site/src/sections/UnitsSection.tsx index 4d2de8c..29b4389 100644 --- a/solution/site/src/sections/UnitsSection.tsx +++ b/solution/site/src/sections/UnitsSection.tsx @@ -33,6 +33,7 @@ export function UnitsSection() { const spec = unitSpec(payload); const booking = stayBookingView(payload); const [openUnit, setOpenUnit] = useState(null); + const [bookingOpen, setBookingOpen] = useState(false); // ★ 격자는 '고르는 화면', 밴드는 '보여 주는 화면'이다. 독채 두 동에는 밴드가 맞다. const layout = useLayout(); if (layout === 'reservation') return ; @@ -133,12 +134,9 @@ export function UnitsSection() { open={openUnit === unit.unitId} onClose={() => setOpenUnit(null)} label={`${unit.name} 상세 · 예약`} - wide={Boolean(booking)} + title={unit.name} > -
-
-

{unit.name}

- +
{offer?.baseRateText && (

{offer.baseRateText} @@ -176,45 +174,51 @@ export function UnitsSection() { ))}

)} - {booking && ( -
-
-

예약

- {booking.phone && ( - - - 전화 예약 {booking.phone} - - )} - {booking.links.map((link) => ( - - {bookingCtaLabel(link)} - - - ))} -
- - {/* 날짜 선택 + 연락처를 남기면 사장님 메일로 가는 예약 요청 — 실제 예약을 - 확정하지 않는다(PRODUCT.md 6절), StayBookingDemo 머리주석 참고. */} - +
+

예약

+ + {booking.phone && ( + + + 전화 예약 {booking.phone} + + )} + {booking.links.map((link) => ( + + {bookingCtaLabel(link)} + + + ))}
)}
); })} + + {booking && ( + setBookingOpen(false)} label="예약 요청" title="예약 요청" wide> + + + )} ); } diff --git a/solution/site/src/sections/WeatherSection.tsx b/solution/site/src/sections/WeatherSection.tsx index 073139c..d49ff95 100644 --- a/solution/site/src/sections/WeatherSection.tsx +++ b/solution/site/src/sections/WeatherSection.tsx @@ -83,7 +83,7 @@ export function WeatherSection() {

{observed && ( - + {observed} {weather.stale && ' · 최근 관측값'} @@ -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" > {band} diff --git a/solution/site/src/sections/blog.test.tsx b/solution/site/src/sections/blog.test.tsx index 78576cd..d408f91 100644 --- a/solution/site/src/sections/blog.test.tsx +++ b/solution/site/src/sections/blog.test.tsx @@ -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(/요일/); }); diff --git a/solution/site/src/sections/items/EventSection.tsx b/solution/site/src/sections/items/EventSection.tsx index b698484..e9be1c0 100644 --- a/solution/site/src/sections/items/EventSection.tsx +++ b/solution/site/src/sections/items/EventSection.tsx @@ -95,11 +95,23 @@ export function EventSection() { const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close(); }; - const previous = document.body.style.overflow; - document.body.style.overflow = 'hidden'; + 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'; window.addEventListener('keydown', onKey); return () => { - document.body.style.overflow = previous; + body.style.position = prevPosition; + body.style.top = prevTop; + body.style.width = prevWidth; + body.style.overflow = prevOverflow; + window.scrollTo(0, scrollY); window.removeEventListener('keydown', onKey); }; }, [open, close]); diff --git a/solution/site/src/sections/items/ItinerarySection.tsx b/solution/site/src/sections/items/ItinerarySection.tsx index b2e2b06..dad066c 100644 --- a/solution/site/src/sections/items/ItinerarySection.tsx +++ b/solution/site/src/sections/items/ItinerarySection.tsx @@ -45,15 +45,6 @@ function daysOf(item: ItineraryItem): {label?: string; startTime?: string; stops return [{startTime: item.startTime, stops: item.stops ?? []}]; } -/** 같은 코스의 날짜 카드 사이 점선 이음줄. 레일 안 다른 카드와 같은 flex 흐름을 탄다. */ -function DayConnector() { - return ( -
- -
- ); -} - 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({ * 경계 여백이 남으면 같은 레일 안에서 카드 간격이 두 종류가 되고, 그게 더 눈에 걸린다. */
- {courses.flatMap(({item, start}) => - daysOf(item).flatMap((day, dayIndex) => { - const card = ( - - ); - // ★ 같은 코스의 날짜 카드는 점선으로 잇는다(대표: "같은 일정은 붙어있게, 점선으로") — - // 코스가 갈리는 경계에는 이 이음줄이 없어 레일 위에서 그대로 구분된다. - if (dayIndex === 0) return [card]; - return [, card]; - }), - )} + {courses.flatMap(({item, start}) => { + const days = daysOf(item); + // ★ 같은 코스의 날짜 카드는 붙여서 한 판처럼 잇는다 — 앞 카드 오른쪽 테두리를 + // 점선으로, 뒷 카드 왼쪽 테두리는 지운다(대표: "같은 일정이면 띄어놓지 말라고"). + // 코스가 갈리는 경계에는 이 처리가 없어 레일 위에서 그대로 구분된다. + return days.map((day, dayIndex) => ( + 0} + continuesTo={dayIndex < days.length - 1} + /> + )); + })} ); diff --git a/solution/site/src/sections/items/StorySection.tsx b/solution/site/src/sections/items/StorySection.tsx index 2de08f0..f4a0c21 100644 --- a/solution/site/src/sections/items/StorySection.tsx +++ b/solution/site/src/sections/items/StorySection.tsx @@ -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 = { 지명을 코드에 박지 않는다(`군산 읽기` 는 시연본 한 곳의 값이다). */ 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, }} >
@@ -108,11 +109,13 @@ export function StorySection() {
- {tabs.map((tab, index) => ( - - ))} + + {tabs.map((tab, index) => ( + + ))} + ); } diff --git a/solution/site/src/sections/items/VideoSection.tsx b/solution/site/src/sections/items/VideoSection.tsx index 7424b79..9187131 100644 --- a/solution/site/src/sections/items/VideoSection.tsx +++ b/solution/site/src/sections/items/VideoSection.tsx @@ -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() {
  • diff --git a/solution/site/src/sections/items/common.tsx b/solution/site/src/sections/items/common.tsx index d9610d5..d99f89b 100644 --- a/solution/site/src/sections/items/common.tsx +++ b/solution/site/src/sections/items/common.tsx @@ -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 ? ( ) : ( -
    +
    {/* 제목과 바깥 링크를 한 줄에. 좁은 화면에서는 링크가 아래로 떨어진다. */}
    -

    +

    {name}

    {linkNode} diff --git a/solution/site/src/sections/stay-guide.test.tsx b/solution/site/src/sections/stay-guide.test.tsx index dc4af1c..936e2d5 100644 --- a/solution/site/src/sections/stay-guide.test.tsx +++ b/solution/site/src/sections/stay-guide.test.tsx @@ -29,7 +29,6 @@ it('renders only the reservation notice from guides, before booking actions, esc expect(html).toContain('<script>'); expect(html).not.toContain('