Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO
This commit is contained in:
commit
d358ff4b93
@ -347,6 +347,79 @@ async def test_post_claim_prevents_second_external_write(
|
|||||||
).scalar_one() == ("UNKNOWN" if unknown else "POSTED")
|
).scalar_one() == ("UNKNOWN" if unknown else "POSTED")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_test_post_requires_connected_account(client, auth_headers):
|
||||||
|
h = await auth_headers("social-test-owner")
|
||||||
|
res = await client.post("/v1/social/test-post", headers=h, json={"text": "연동 확인"})
|
||||||
|
assert (
|
||||||
|
res.status_code == 409 and res.json()["detail"] == "ACCOUNT_CONNECTION_REQUIRED"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_test_post_requires_reauth_when_expired(client, auth_headers, db_engine, monkeypatch):
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", Fernet.generate_key().decode())
|
||||||
|
h = await auth_headers("social-reauth-owner")
|
||||||
|
async with db_engine.begin() as c:
|
||||||
|
uid = (
|
||||||
|
await c.execute(
|
||||||
|
text("SELECT user_id FROM users WHERE id=:i"), {"i": "social-reauth-owner"}
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
await c.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) "
|
||||||
|
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','needs_reauth')"
|
||||||
|
),
|
||||||
|
{"a": uuid.uuid4(), "u": uid},
|
||||||
|
)
|
||||||
|
res = await client.post("/v1/social/test-post", headers=h, json={"text": "연동 확인"})
|
||||||
|
assert (
|
||||||
|
res.status_code == 409 and res.json()["detail"] == "ACCOUNT_NEEDS_REAUTH"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_test_post_publishes_immediately_bypassing_approval(
|
||||||
|
client, auth_headers, db_engine, monkeypatch
|
||||||
|
):
|
||||||
|
"""연동 확인용 게시는 posting_enabled 게이트·승인 절차 없이 바로 나간다."""
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from services import social_account_service as accounts
|
||||||
|
|
||||||
|
monkeypatch.setenv("SOCIAL_TOKEN_SECRET", Fernet.generate_key().decode())
|
||||||
|
monkeypatch.setattr(service, "posting_enabled", lambda: False)
|
||||||
|
h = await auth_headers("social-verify-owner")
|
||||||
|
async with db_engine.begin() as c:
|
||||||
|
uid = (
|
||||||
|
await c.execute(
|
||||||
|
text("SELECT user_id FROM users WHERE id=:i"), {"i": "social-verify-owner"}
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
await c.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status,access_token,access_expires_at) "
|
||||||
|
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked',:t,now()+interval '1 day')"
|
||||||
|
),
|
||||||
|
{"a": uuid.uuid4(), "u": uid, "t": accounts.encrypt("secret-token")},
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Adapter:
|
||||||
|
@staticmethod
|
||||||
|
async def publish(text_, token, *, client):
|
||||||
|
calls.append((text_, token))
|
||||||
|
return {"id": "99", "permalink": "https://www.threads.com/@host/post/99"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "adapter", lambda provider: Adapter)
|
||||||
|
res = await client.post(
|
||||||
|
"/v1/social/test-post", headers=h, json={"text": "연동 확인용 테스트"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
assert res.json() == {"id": "99", "permalink": "https://www.threads.com/@host/post/99"}
|
||||||
|
assert calls == [("연동 확인용 테스트", "secret-token")]
|
||||||
|
|
||||||
|
|
||||||
async def test_oauth_roundtrip_saves_encrypted_account(db_engine, monkeypatch):
|
async def test_oauth_roundtrip_saves_encrypted_account(db_engine, monkeypatch):
|
||||||
"""검증: 인가 코드를 받아 계정을 연결하고, 해제까지 한 바퀴 돈다.
|
"""검증: 인가 코드를 받아 계정을 연결하고, 해제까지 한 바퀴 돈다.
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import {useEffect} from 'react';
|
import {useEffect, useRef} from 'react';
|
||||||
import type {ReactNode} from 'react';
|
import type {ReactNode} from 'react';
|
||||||
import {X} from 'lucide-react';
|
import {X} from 'lucide-react';
|
||||||
|
|
||||||
@ -13,29 +13,47 @@ export function Modal({
|
|||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
label,
|
label,
|
||||||
|
title,
|
||||||
children,
|
children,
|
||||||
wide,
|
wide,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
label: string;
|
label: string;
|
||||||
|
/** 닫기 버튼과 한 줄에 놓일 제목. 없으면 닫기 버튼만 뜬다. */
|
||||||
|
title?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** 안이 2열(정보+예약)로 갈리는 경우처럼 lg 폭이 필요할 때. */
|
/** 안이 2열(정보+예약)로 갈리는 경우처럼 lg 폭이 필요할 때. */
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') onClose();
|
if (event.key === 'Escape') onCloseRef.current();
|
||||||
};
|
};
|
||||||
const previous = document.body.style.overflow;
|
const scrollY = window.scrollY;
|
||||||
document.body.style.overflow = 'hidden';
|
const body = document.body;
|
||||||
|
const prevPosition = body.style.position;
|
||||||
|
const prevTop = body.style.top;
|
||||||
|
const prevWidth = body.style.width;
|
||||||
|
const prevOverflow = body.style.overflow;
|
||||||
|
body.style.position = 'fixed';
|
||||||
|
body.style.top = `-${scrollY}px`;
|
||||||
|
body.style.width = '100%';
|
||||||
|
body.style.overflow = 'hidden';
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previous;
|
body.style.position = prevPosition;
|
||||||
|
body.style.top = prevTop;
|
||||||
|
body.style.width = prevWidth;
|
||||||
|
body.style.overflow = prevOverflow;
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
};
|
};
|
||||||
}, [open, onClose]);
|
}, [open]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@ -51,16 +69,18 @@ export function Modal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={`absolute inset-x-0 bottom-0 max-h-[88svh] overflow-auto rounded-t-2xl px-5 pb-8 pt-14 sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:max-h-[85vh] sm:w-full sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl ${
|
className={`absolute inset-x-0 bottom-0 flex max-h-[88svh] flex-col overflow-hidden rounded-t-2xl sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:max-h-[85vh] sm:w-full sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl ${
|
||||||
wide ? 'sm:max-w-3xl' : 'sm:max-w-lg'
|
wide ? 'sm:max-w-3xl' : 'sm:max-w-lg'
|
||||||
}`}
|
}`}
|
||||||
style={{backgroundColor: 'var(--color-surface)'}}
|
style={{backgroundColor: 'var(--color-surface)'}}
|
||||||
>
|
>
|
||||||
|
<div className="border-line flex shrink-0 items-center justify-between gap-3 border-b p-5 pb-4">
|
||||||
|
{title && <h3 className="h3 min-w-0 truncate">{title}</h3>}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label="닫기"
|
aria-label="닫기"
|
||||||
className="tap absolute right-3 top-3 z-10 flex size-10 items-center justify-center rounded-full shadow-md"
|
className="tap ml-auto flex size-10 shrink-0 items-center justify-center rounded-full shadow-md"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-surface)',
|
backgroundColor: 'var(--color-surface)',
|
||||||
boxShadow: '0 1px 2px rgba(0,0,0,.18), 0 0 0 1px color-mix(in oklab, currentColor 12%, transparent)',
|
boxShadow: '0 1px 2px rgba(0,0,0,.18), 0 0 0 1px color-mix(in oklab, currentColor 12%, transparent)',
|
||||||
@ -68,7 +88,8 @@ export function Modal({
|
|||||||
>
|
>
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
{children}
|
</div>
|
||||||
|
<div className="overflow-y-auto p-5 pb-8">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,72 +1,115 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
|
import type {PostEntry} from '@o2o/shared';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
import {formatKoreanDate} from '@site/lib/format';
|
import {isoDate} from '@site/lib/format';
|
||||||
import {Section} from '@site/lib/ui';
|
import {Carousel, CarouselSlide, Modal, Section} from '@site/lib/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미니 블로그 — 사장님이 승인한 짧은 글. 기획: docs/MINI_BLOG.md
|
* 미니 블로그 — 사장님이 승인한 짧은 글. 기획: docs/MINI_BLOG.md
|
||||||
*
|
*
|
||||||
* ★ 글 전부가 HTML 안에 있고 화면만 나눠 보여준다. 페이지를 눌렀을 때 더 불러오면
|
* ★ 글 전부가 HTML 안에 있고 카로셀이 옆으로만 보여준다. 슬라이드는 전부 항상 문서에 있다
|
||||||
* 크롤러는 2페이지를 못 본다 — 이 사이트가 존재하는 이유가 그 읽힘이다.
|
* (`Carousel` 의 존재 이유 — 「스크립트 없이도 내용이 보여야 한다」머리주석 참고) —
|
||||||
* ★ 사진은 없다(회의 확정). 글만이라 카드가 아니라 줄 목록이다.
|
* 페이지를 눌러야 더 나오는 방식이 아니라서 크롤러가 못 보는 글이 없다.
|
||||||
|
* ★ 사진은 없다(회의 확정). 일력처럼 월·일을 크게 세우고 점선 테두리·구멍을 입힌 1차 판은
|
||||||
|
* 2026-09-18 대표: "UI가 좀 유치한 것 같은데" — 장식을 걷어냈다가, 이번엔 `tpl-border`
|
||||||
|
* (사이트 전역 굵은 테두리 값)만 남아 "border2 블랙 촌스럽다" — 결국 카드는 다른 목록형
|
||||||
|
* 컴포넌트(`SpotCard`)와 같은 `.panel`(테마 토큰 기반 옅은 테두리·그림자)로 맞추고,
|
||||||
|
* 계절은 배경을 살짝 물들이는 정도로만 남긴다(색은 사실이 아니라 디자인 값 —
|
||||||
|
* `SongsSection.tsx` `LABEL_PALETTE` 머리주석과 같은 이유).
|
||||||
|
* ★ 본문은 카드 안에서 줄임(line-clamp)되므로 눌러서 전문을 읽는 모달을 둔다
|
||||||
|
* (2026-09-18 대표: "클릭했을 때 모달도 안 띄우네" — `SpotCard`/`ReviewSection` 과 같은
|
||||||
|
* `Modal` 재사용). 카드가 잘라 보여줘도 그 전문은 이미 HTML 에 있다 — 모달은 그걸
|
||||||
|
* 다시 부르는 게 아니라 같은 문자열을 화면에 크게 펼치는 것뿐이다.
|
||||||
|
* ★ **모달은 카드(=캐러셀 슬라이드) 밖, `Section` 바로 아래 하나만 둔다** — 카드 안에
|
||||||
|
* 하나씩 넣었다가 실제로 열어 보니 잘려 나오고 스크롤 잠금도 안 풀렸다(2026-09-18 대표:
|
||||||
|
* "나랑 장난하니?", "스크롤이 자꾸 없어지지?"). 원인은 embla — `.slider-track` 을
|
||||||
|
* `transform` 으로 밀어 스크롤을 흉내내는데, `position: fixed` 는 조상에 `transform` 이
|
||||||
|
* 있으면 뷰포트가 아니라 **그 조상**을 기준으로 눕는다(CSS containing block 규칙). 슬라이드
|
||||||
|
* 안의 모달은 그 트랙 박스 안에 갇혀 `overflow-x:hidden` 에 잘리고, 트랙이 옮겨지면 같이
|
||||||
|
* 움직여 보였다 안 보였다 했다 — `SpotCard`(그리드, 캐러셀 아님)에서는 안 나던 문제다.
|
||||||
|
* 그래서 열린 글의 id 만 상태로 들고, `Modal` 은 `Carousel` 형제로 한 번만 그린다.
|
||||||
|
* ★ loop 를 켠다 — 다만 슬라이드 자체는 `Carousel` 공용 트랙(gap)을 그대로 쓴다.
|
||||||
|
* 예전에 다른 레일에서 embla loop 이 이음매 간격을 깨뜨린 적이 있다(2026-09-09 기록,
|
||||||
|
* `Carousel.tsx` 머리주석) — 카드 폭이 좁고 개수가 많은 이 레일에서 같은 증상이 있는지는
|
||||||
|
* 배포 후 실제로 끝까지 밀어 넘겨서 확인한다.
|
||||||
*/
|
*/
|
||||||
const PER_PAGE = 10;
|
type Season = 'spr' | 'sum' | 'aut' | 'win';
|
||||||
|
|
||||||
|
const SEASON_COLOR: Record<Season, string> = {
|
||||||
|
spr: '#4f7942',
|
||||||
|
sum: '#c9820a',
|
||||||
|
aut: '#bf2f1b',
|
||||||
|
win: '#3b6ea5',
|
||||||
|
};
|
||||||
|
|
||||||
|
function seasonOf(month: number): Season {
|
||||||
|
if (month >= 3 && month <= 5) return 'spr';
|
||||||
|
if (month >= 6 && month <= 8) return 'sum';
|
||||||
|
if (month >= 9 && month <= 11) return 'aut';
|
||||||
|
return 'win';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO → 한국 시간 기준 월·일·요일. `isoDate` 와 같은 이유로 타임존을 명시한다. */
|
||||||
|
function kstDateParts(iso: string) {
|
||||||
|
const [, month, day] = isoDate(iso).split('-').map(Number);
|
||||||
|
const weekday = new Intl.DateTimeFormat('ko-KR', {weekday: 'long', timeZone: 'Asia/Seoul'}).format(new Date(iso));
|
||||||
|
return {month, day, weekday};
|
||||||
|
}
|
||||||
|
|
||||||
export function BlogSection() {
|
export function BlogSection() {
|
||||||
const payload = useSite();
|
const payload = useSite();
|
||||||
const posts = payload.posts ?? [];
|
const posts = payload.posts ?? [];
|
||||||
const [page, setPage] = useState(0);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
|
||||||
if (posts.length === 0) return null;
|
if (posts.length === 0) return null;
|
||||||
|
|
||||||
const pages = Math.ceil(posts.length / PER_PAGE);
|
const openPost = posts.find((post) => post.postId === openId) ?? null;
|
||||||
|
const openDate = openPost ? kstDateParts(openPost.publishedAt) : null;
|
||||||
|
const openLabel = openDate ? `${openDate.month}월 ${openDate.day}일 · ${openDate.weekday}` : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section
|
<Section
|
||||||
id="blog"
|
id="blog"
|
||||||
tone="alt"
|
tone="alt"
|
||||||
title={`${payload.place.name}의 기록`}
|
title="미니 블로그"
|
||||||
lead="사장님이 띄엄띄엄 남기는 짧은 글입니다."
|
lead="사장님이 남기는 숙소 이야기입니다."
|
||||||
>
|
>
|
||||||
<ul className="divide-line divide-y">
|
<Carousel label={`${payload.place.name} 미니 블로그`} align="start" gap={0.875} loop>
|
||||||
{/* 전부 그리고 이번 장이 아닌 것만 감춘다 — 잘라내면 구운 HTML 에 안 남는다. */}
|
{posts.map((post) => (
|
||||||
{posts.map((post, index) => (
|
<CarouselSlide key={post.postId} basis="basis-[225px]">
|
||||||
<li
|
<PostCard post={post} onOpen={() => setOpenId(post.postId)} />
|
||||||
key={post.postId}
|
</CarouselSlide>
|
||||||
hidden={Math.floor(index / PER_PAGE) !== page}
|
|
||||||
className="flex flex-col gap-1.5 py-4"
|
|
||||||
>
|
|
||||||
<time
|
|
||||||
dateTime={post.publishedAt}
|
|
||||||
className="text-muted text-[length:var(--fs-xs)] tabular-nums"
|
|
||||||
>
|
|
||||||
{formatKoreanDate(post.publishedAt)}
|
|
||||||
</time>
|
|
||||||
<p className="measure whitespace-pre-line break-keep text-[length:var(--fs-body)] leading-relaxed">
|
|
||||||
{post.body}
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Carousel>
|
||||||
|
|
||||||
{pages > 1 && (
|
<Modal open={openPost != null} onClose={() => setOpenId(null)} label={`미니 블로그 · ${openLabel}`} title={openLabel}>
|
||||||
<nav aria-label="글 페이지" className="mt-5 flex flex-wrap justify-center gap-1.5">
|
{openPost && (
|
||||||
{Array.from({length: pages}, (_, index) => (
|
<p className="whitespace-pre-line break-keep text-[length:var(--fs-body)] leading-relaxed">{openPost.body}</p>
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPage(index)}
|
|
||||||
aria-current={index === page ? 'page' : undefined}
|
|
||||||
className="border-line tpl-border min-w-9 rounded border px-3 py-1.5 text-[length:var(--fs-sm)] font-semibold tabular-nums"
|
|
||||||
style={index === page
|
|
||||||
? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}
|
|
||||||
: undefined}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
)}
|
)}
|
||||||
|
</Modal>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PostCard({post, onOpen}: {post: PostEntry; onOpen: () => void}) {
|
||||||
|
const {month, day, weekday} = kstDateParts(post.publishedAt);
|
||||||
|
const season = seasonOf(month);
|
||||||
|
const color = SEASON_COLOR[season];
|
||||||
|
const dateLabel = `${month}월 ${day}일 · ${weekday}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className="panel flex aspect-square w-full flex-col gap-2 p-4 text-left"
|
||||||
|
style={{backgroundColor: `color-mix(in srgb, ${color} 9%, var(--tpl-card, #efe7d3))`}}
|
||||||
|
>
|
||||||
|
<time dateTime={post.publishedAt} className="block shrink-0 text-[length:var(--fs-xs)] font-bold tracking-wide" style={{color}}>
|
||||||
|
{dateLabel}
|
||||||
|
</time>
|
||||||
|
<p className="line-clamp-5 min-h-0 flex-1 whitespace-pre-line break-keep text-[length:var(--fs-xs)] leading-relaxed">
|
||||||
|
{post.body}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {ChevronLeft, ChevronRight, X} from 'lucide-react';
|
import {ChevronLeft, ChevronRight, X} from 'lucide-react';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
import {galleryImages} from '@site/lib/derive';
|
import {galleryImages} from '@site/lib/derive';
|
||||||
import {Section} from '@site/lib/ui';
|
import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
|
||||||
import {AUTOPLAY_MS, scrollRailNext, useRailAutoplay} from '@site/lib/ui/use-rail-autoplay';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사진 갤러리.
|
* 사진 갤러리.
|
||||||
@ -19,45 +18,6 @@ export function GallerySection() {
|
|||||||
const variantId = setting?.variantId ?? 'photos.grid';
|
const variantId = setting?.variantId ?? 'photos.grid';
|
||||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// 좁은 화면 캐러셀의 현재 장 · 양끝 여부. 넓은 화면은 격자라 쓰이지 않는다.
|
|
||||||
const track = useRef<HTMLUListElement>(null);
|
|
||||||
const [slide, setSlide] = useState(0);
|
|
||||||
const [edge, setEdge] = useState({prev: false, next: true});
|
|
||||||
|
|
||||||
const syncTrack = useCallback(() => {
|
|
||||||
const box = track.current;
|
|
||||||
if (!box) return;
|
|
||||||
const step = box.clientWidth;
|
|
||||||
// 소수점 폭 때문에 scrollLeft 가 끝에 정확히 닿지 못한다 — 8px 여유를 둔다.
|
|
||||||
const max = box.scrollWidth - step;
|
|
||||||
setSlide(step > 0 ? Math.round(box.scrollLeft / step) : 0);
|
|
||||||
setEdge({prev: box.scrollLeft > 8, next: box.scrollLeft < max - 8});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
syncTrack();
|
|
||||||
window.addEventListener('resize', syncTrack);
|
|
||||||
return () => window.removeEventListener('resize', syncTrack);
|
|
||||||
}, [syncTrack]);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 자동 넘김 — 카드 레일과 같은 정책이다(`useRailAutoplay`).
|
|
||||||
* 넓은 화면에서는 격자라 넘길 데가 없고, 그때 `scrollRailNext` 가 false 를 돌려 스스로 멈춘다.
|
|
||||||
* 사진은 한 장이 화면을 다 채우므로 한 번에 한 장씩 넘긴다(비율 1).
|
|
||||||
*/
|
|
||||||
useRailAutoplay({
|
|
||||||
box: track,
|
|
||||||
interval: AUTOPLAY_MS,
|
|
||||||
advance: () => scrollRailNext(track.current, 1),
|
|
||||||
});
|
|
||||||
|
|
||||||
const nudge = useCallback((dir: -1 | 1) => {
|
|
||||||
const box = track.current;
|
|
||||||
if (!box) return;
|
|
||||||
const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
box.scrollBy({left: dir * box.clientWidth, behavior: still ? 'auto' : 'smooth'});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const close = useCallback(() => setOpenIndex(null), []);
|
const close = useCallback(() => setOpenIndex(null), []);
|
||||||
const step = useCallback(
|
const step = useCallback(
|
||||||
(delta: number) =>
|
(delta: number) =>
|
||||||
@ -76,11 +36,23 @@ export function GallerySection() {
|
|||||||
if (event.key === 'ArrowLeft') step(-1);
|
if (event.key === 'ArrowLeft') step(-1);
|
||||||
if (event.key === 'ArrowRight') step(1);
|
if (event.key === 'ArrowRight') step(1);
|
||||||
};
|
};
|
||||||
const previous = document.body.style.overflow;
|
const scrollY = window.scrollY;
|
||||||
document.body.style.overflow = 'hidden';
|
const body = document.body;
|
||||||
|
const prevPosition = body.style.position;
|
||||||
|
const prevTop = body.style.top;
|
||||||
|
const prevWidth = body.style.width;
|
||||||
|
const prevOverflow = body.style.overflow;
|
||||||
|
body.style.position = 'fixed';
|
||||||
|
body.style.top = `-${scrollY}px`;
|
||||||
|
body.style.width = '100%';
|
||||||
|
body.style.overflow = 'hidden';
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previous;
|
body.style.position = prevPosition;
|
||||||
|
body.style.top = prevTop;
|
||||||
|
body.style.width = prevWidth;
|
||||||
|
body.style.overflow = prevOverflow;
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
};
|
};
|
||||||
}, [openIndex, close, step]);
|
}, [openIndex, close, step]);
|
||||||
@ -91,29 +63,26 @@ export function GallerySection() {
|
|||||||
<Section id="gallery" title={setting?.name || '공간 갤러리'}>
|
<Section id="gallery" title={setting?.name || '공간 갤러리'}>
|
||||||
{/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */}
|
{/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */}
|
||||||
{variantId === 'photos.carousel' ? (
|
{variantId === 'photos.carousel' ? (
|
||||||
/*
|
<>
|
||||||
* ★ 캐러셀은 좁은 화면에서만이다. 넓은 화면에서 옆으로 밀게 두면 사진이 화면 폭의
|
{/* ★ 좁은 화면만 카로셀이다 — 넓은 화면에서 옆으로 밀게 두면 사진이 화면 폭의
|
||||||
* 절반도 못 쓰고 작게 남는다 — 넓으면 격자가 더 많이, 더 크게 보여준다.
|
절반도 못 쓰고 작게 남는다. 폭으로 컴포넌트를 갈아 끼우지 않고 CSS 로만
|
||||||
* ★ 폭으로 컴포넌트를 갈아 끼우지 않고 CSS 로만 가른다. 서버 렌더는 화면 폭을 모르니
|
가른다(서버 렌더는 화면 폭을 모른다) — 그래서 두 마크업이 같이 있다. */}
|
||||||
* 렌더 중에 matchMedia 를 읽으면 하이드레이션 결과가 어긋난다.
|
<div className="lg:hidden">
|
||||||
* 덤으로 스크립트 없이도 손가락으로 밀린다.
|
<Carousel label="공간 갤러리" align="center" gap={0} arrows="overlay">
|
||||||
*
|
|
||||||
* ★ 한 장씩 꽉 채운다 (2026-09-04, 사장님 지적: "모바일 처리가 안 돼 있다")
|
|
||||||
* 82% 로 다음 장을 걸쳐 보이게 두었더니 모바일에서 사진이 작고, 옆으로 더 있다는
|
|
||||||
* 신호가 그 걸침 하나뿐이라 **아무도 밀지 않았다.** 네이버 플레이스가 하는 대로
|
|
||||||
* 한 장을 폭에 꽉 채우고, 신호를 **장수 표시(n/N)와 좌우 버튼**으로 바꾼다.
|
|
||||||
* 좁은 화면에서 버튼은 손가락이 아니라 "더 있다"는 표시로 먼저 일한다.
|
|
||||||
*/
|
|
||||||
<div className="relative">
|
|
||||||
<ul
|
|
||||||
ref={track}
|
|
||||||
onScroll={syncTrack}
|
|
||||||
/* 사진 모자이크는 12px 한 값이다 — 모바일 8px·데스크톱 12px 로 갈라 두면
|
|
||||||
같은 사진 목록이 폭에 따라 다른 물건처럼 보인다(카드 레일은 16px, Carousel 주석). */
|
|
||||||
className="flex snap-x snap-mandatory gap-3 overflow-x-auto [touch-action:pan-y_pinch-zoom] [scrollbar-width:none] lg:grid lg:grid-cols-4 lg:gap-3 lg:snap-none lg:overflow-visible [&::-webkit-scrollbar]:hidden"
|
|
||||||
>
|
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<li key={image.mediaId} className="w-full shrink-0 snap-center lg:w-auto">
|
<CarouselSlide key={image.mediaId} basis="basis-full">
|
||||||
|
<GalleryImage
|
||||||
|
image={image}
|
||||||
|
onOpen={() => setOpenIndex(index)}
|
||||||
|
className="aspect-4/3 rounded-lg"
|
||||||
|
/>
|
||||||
|
</CarouselSlide>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
<ul className="hidden lg:grid lg:grid-cols-4 lg:gap-3">
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<li key={image.mediaId}>
|
||||||
<GalleryImage
|
<GalleryImage
|
||||||
image={image}
|
image={image}
|
||||||
onOpen={() => setOpenIndex(index)}
|
onOpen={() => setOpenIndex(index)}
|
||||||
@ -122,18 +91,7 @@ export function GallerySection() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* 좌우 버튼 · 장수 — 격자가 되는 넓은 화면에서는 없앤다(밀 것이 없다). */}
|
|
||||||
{images.length > 1 && (
|
|
||||||
<>
|
|
||||||
<TrackNav dir="prev" show={edge.prev} onClick={() => nudge(-1)} />
|
|
||||||
<TrackNav dir="next" show={edge.next} onClick={() => nudge(1)} />
|
|
||||||
<span className="pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/55 px-2.5 py-1 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white lg:hidden">
|
|
||||||
{Math.min(slide + 1, images.length)} / {images.length}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : variantId === 'photos.masonry' ? (
|
) : variantId === 'photos.masonry' ? (
|
||||||
<ul className="columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3">
|
<ul className="columns-2 gap-3 sm:columns-3 lg:columns-4 [&>li]:mb-3">
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
@ -203,24 +161,6 @@ export function GallerySection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 캐러셀 좌우 버튼 — 끝에 닿으면 지운다. 눌러도 안 움직이는 버튼은 고장으로 읽힌다. */
|
|
||||||
function TrackNav({dir, show, onClick}: {dir: 'prev' | 'next'; show: boolean; onClick: () => void}) {
|
|
||||||
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={dir === 'prev' ? '이전 사진' : '다음 사진'}
|
|
||||||
aria-hidden={!show}
|
|
||||||
tabIndex={show ? 0 : -1}
|
|
||||||
className={`absolute top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${
|
|
||||||
show ? 'opacity-100' : 'pointer-events-none opacity-0'
|
|
||||||
} ${dir === 'prev' ? 'left-2' : 'right-2'}`}
|
|
||||||
>
|
|
||||||
<Icon className="size-5" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LightboxNav({dir, onClick}: {dir: 'prev' | 'next'; onClick: () => void}) {
|
function LightboxNav({dir, onClick}: {dir: 'prev' | 'next'; onClick: () => void}) {
|
||||||
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
|
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
import {MapPin, MessageCircle, Phone} from 'lucide-react';
|
import {MapPin, MessageCircle, Phone} from 'lucide-react';
|
||||||
|
import {PlaceCategory} from '@o2o/shared';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
import {bookingActionLabel, bookingLinks, channelLabel} from '@site/lib/derive';
|
import {bookingActionLabel, bookingLinks, channelLabel, stayBookingView} from '@site/lib/derive';
|
||||||
|
import {Modal} from '@site/lib/ui';
|
||||||
|
import {StayBookingDemo} from './StayBookingDemo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 좁은 화면 하단 고정 바.
|
* 좁은 화면 하단 고정 바.
|
||||||
@ -19,10 +23,13 @@ export function MobileTabBar() {
|
|||||||
const links = bookingLinks(payload);
|
const links = bookingLinks(payload);
|
||||||
const booking = links[0];
|
const booking = links[0];
|
||||||
const kakao = payload.links.find((link) => link.confirmed && /kakao/i.test(link.url));
|
const kakao = payload.links.find((link) => link.confirmed && /kakao/i.test(link.url));
|
||||||
|
const stayBooking = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
||||||
|
const [bookingOpen, setBookingOpen] = useState(false);
|
||||||
|
|
||||||
if (!phone && !booking && !kakao) return null;
|
if (!phone && !booking && !kakao) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<nav
|
<nav
|
||||||
aria-label="연락 · 예약"
|
aria-label="연락 · 예약"
|
||||||
className="border-line safe-b fixed inset-x-0 bottom-0 z-50 flex items-stretch gap-2 border-t px-3 pt-2 backdrop-blur-md lg:hidden"
|
className="border-line safe-b fixed inset-x-0 bottom-0 z-50 flex items-stretch gap-2 border-t px-3 pt-2 backdrop-blur-md lg:hidden"
|
||||||
@ -61,6 +68,16 @@ export function MobileTabBar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{booking && (
|
{booking && (
|
||||||
|
stayBooking ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBookingOpen(true)}
|
||||||
|
className="tap flex min-w-0 flex-[1.4] items-center justify-center truncate rounded-lg px-2 text-[length:var(--fs-sm)] font-bold"
|
||||||
|
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
||||||
|
>
|
||||||
|
예약하기
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<a
|
<a
|
||||||
href={booking.url}
|
href={booking.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -72,7 +89,15 @@ export function MobileTabBar() {
|
|||||||
390px 바에서 버튼 밖으로 넘쳤다. 버튼이 답할 건 '어디서 예약하나' 하나다. */}
|
390px 바에서 버튼 밖으로 넘쳤다. 버튼이 답할 건 '어디서 예약하나' 하나다. */}
|
||||||
{bookingActionLabel(booking)}
|
{bookingActionLabel(booking)}
|
||||||
</a>
|
</a>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{stayBooking && (
|
||||||
|
<Modal open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" title="예약 요청" wide>
|
||||||
|
<StayBookingDemo />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
|
import {ExternalLink, Mail, MapPin, Phone} from 'lucide-react';
|
||||||
|
import {PlaceCategory} from '@o2o/shared';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
import {isoDate} from '@site/lib/format';
|
import {isoDate} from '@site/lib/format';
|
||||||
// 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다.
|
// 채널 이름표는 예약·문의 섹션과 같은 표를 쓴다 — 같은 채널이 자리마다 다른 이름으로 뜨면 안 된다.
|
||||||
import {channelLabel, primaryChannelLink} from '@site/lib/derive';
|
import {bookingLabel, primaryChannelLink, stayBookingView} from '@site/lib/derive';
|
||||||
|
import {Modal} from '@site/lib/ui';
|
||||||
|
import {StayBookingDemo} from './StayBookingDemo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 푸터.
|
* 푸터.
|
||||||
@ -17,6 +21,8 @@ export function SiteFooter() {
|
|||||||
// ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
|
// ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
|
||||||
const link = primaryChannelLink(payload);
|
const link = primaryChannelLink(payload);
|
||||||
const address = place.roadAddress ?? place.address;
|
const address = place.roadAddress ?? place.address;
|
||||||
|
const booking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
||||||
|
const [bookingOpen, setBookingOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
@ -62,15 +68,25 @@ export function SiteFooter() {
|
|||||||
<h2 className="label mb-3 !text-current opacity-100">공식 채널</h2>
|
<h2 className="label mb-3 !text-current opacity-100">공식 채널</h2>
|
||||||
<ul className="flex flex-wrap gap-2">
|
<ul className="flex flex-wrap gap-2">
|
||||||
<li>
|
<li>
|
||||||
|
{booking ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBookingOpen(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10"
|
||||||
|
>
|
||||||
|
<span>예약하기</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<a
|
<a
|
||||||
href={link.url}
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10"
|
className="inline-flex items-center gap-1.5 rounded-lg border border-current/25 px-3 py-2 text-[length:var(--fs-xs)] font-medium transition-colors hover:bg-current/10"
|
||||||
>
|
>
|
||||||
<span>{channelLabel(link)}</span>
|
<span>{bookingLabel(link)}</span>
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
@ -129,6 +145,12 @@ export function SiteFooter() {
|
|||||||
의 Web4Ai로 만든 사이트입니다.
|
의 Web4Ai로 만든 사이트입니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{booking && (
|
||||||
|
<Modal open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" title="예약 요청" wide>
|
||||||
|
<StayBookingDemo />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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')},
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export function UnitsSection() {
|
|||||||
const spec = unitSpec(payload);
|
const spec = unitSpec(payload);
|
||||||
const booking = stayBookingView(payload);
|
const booking = stayBookingView(payload);
|
||||||
const [openUnit, setOpenUnit] = useState<string | null>(null);
|
const [openUnit, setOpenUnit] = useState<string | null>(null);
|
||||||
|
const [bookingOpen, setBookingOpen] = useState(false);
|
||||||
// ★ 격자는 '고르는 화면', 밴드는 '보여 주는 화면'이다. 독채 두 동에는 밴드가 맞다.
|
// ★ 격자는 '고르는 화면', 밴드는 '보여 주는 화면'이다. 독채 두 동에는 밴드가 맞다.
|
||||||
const layout = useLayout();
|
const layout = useLayout();
|
||||||
if (layout === 'reservation') return <ReservationRooms />;
|
if (layout === 'reservation') return <ReservationRooms />;
|
||||||
@ -133,12 +134,9 @@ export function UnitsSection() {
|
|||||||
open={openUnit === unit.unitId}
|
open={openUnit === unit.unitId}
|
||||||
onClose={() => setOpenUnit(null)}
|
onClose={() => setOpenUnit(null)}
|
||||||
label={`${unit.name} 상세 · 예약`}
|
label={`${unit.name} 상세 · 예약`}
|
||||||
wide={Boolean(booking)}
|
title={unit.name}
|
||||||
>
|
>
|
||||||
<div className={`grid grid-cols-1 gap-6 ${booking ? 'sm:grid-cols-2' : ''}`}>
|
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<h3 className="h3">{unit.name}</h3>
|
|
||||||
|
|
||||||
{offer?.baseRateText && (
|
{offer?.baseRateText && (
|
||||||
<p className="text-[length:var(--fs-lead)] font-bold" style={{color: 'var(--color-brand)'}}>
|
<p className="text-[length:var(--fs-lead)] font-bold" style={{color: 'var(--color-brand)'}}>
|
||||||
{offer.baseRateText}
|
{offer.baseRateText}
|
||||||
@ -176,17 +174,22 @@ export function UnitsSection() {
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{booking && (
|
{booking && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="border-line flex flex-col gap-2 border-t pt-4">
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-[length:var(--fs-sm)] font-bold">예약</p>
|
<p className="text-[length:var(--fs-sm)] font-bold">예약</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setOpenUnit(null); setBookingOpen(true); }}
|
||||||
|
className="tap flex items-center justify-center gap-2 rounded-lg px-4 text-[length:var(--fs-sm)] font-bold"
|
||||||
|
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
||||||
|
>
|
||||||
|
<span>날짜 선택하고 예약 요청하기</span>
|
||||||
|
</button>
|
||||||
{booking.phone && (
|
{booking.phone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${booking.phone}`}
|
href={`tel:${booking.phone}`}
|
||||||
className="tap flex items-center justify-center gap-2 rounded-lg px-4 text-[length:var(--fs-sm)] font-bold"
|
className="tap border-line tpl-border flex items-center justify-center gap-2 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
|
||||||
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
|
||||||
>
|
>
|
||||||
<Phone className="size-4" />
|
<Phone className="size-4" />
|
||||||
<span>전화 예약 {booking.phone}</span>
|
<span>전화 예약 {booking.phone}</span>
|
||||||
@ -205,16 +208,17 @@ export function UnitsSection() {
|
|||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 날짜 선택 + 연락처를 남기면 사장님 메일로 가는 예약 요청 — 실제 예약을
|
|
||||||
확정하지 않는다(PRODUCT.md 6절), StayBookingDemo 머리주석 참고. */}
|
|
||||||
<StayBookingDemo />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{booking && (
|
||||||
|
<Modal open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" title="예약 요청" wide>
|
||||||
|
<StayBookingDemo />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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(/요일/);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -95,11 +95,23 @@ export function EventSection() {
|
|||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') close();
|
if (event.key === 'Escape') close();
|
||||||
};
|
};
|
||||||
const previous = document.body.style.overflow;
|
const scrollY = window.scrollY;
|
||||||
document.body.style.overflow = 'hidden';
|
const body = document.body;
|
||||||
|
const prevPosition = body.style.position;
|
||||||
|
const prevTop = body.style.top;
|
||||||
|
const prevWidth = body.style.width;
|
||||||
|
const prevOverflow = body.style.overflow;
|
||||||
|
body.style.position = 'fixed';
|
||||||
|
body.style.top = `-${scrollY}px`;
|
||||||
|
body.style.width = '100%';
|
||||||
|
body.style.overflow = 'hidden';
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previous;
|
body.style.position = prevPosition;
|
||||||
|
body.style.top = prevTop;
|
||||||
|
body.style.width = prevWidth;
|
||||||
|
body.style.overflow = prevOverflow;
|
||||||
|
window.scrollTo(0, scrollY);
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
};
|
};
|
||||||
}, [open, close]);
|
}, [open, close]);
|
||||||
|
|||||||
@ -45,15 +45,6 @@ function daysOf(item: ItineraryItem): {label?: string; startTime?: string; stops
|
|||||||
return [{startTime: item.startTime, stops: item.stops ?? []}];
|
return [{startTime: item.startTime, stops: item.stops ?? []}];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 같은 코스의 날짜 카드 사이 점선 이음줄. 레일 안 다른 카드와 같은 flex 흐름을 탄다. */
|
|
||||||
function DayConnector() {
|
|
||||||
return (
|
|
||||||
<div className="flex w-6 shrink-0 items-center self-stretch" aria-hidden>
|
|
||||||
<span className="w-full border-t-2 border-dashed" style={{borderColor: ITEM_BORDER}} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DayCard({
|
function DayCard({
|
||||||
item,
|
item,
|
||||||
badge,
|
badge,
|
||||||
@ -61,6 +52,8 @@ function DayCard({
|
|||||||
stops,
|
stops,
|
||||||
first,
|
first,
|
||||||
placeName,
|
placeName,
|
||||||
|
continuesFrom,
|
||||||
|
continuesTo,
|
||||||
}: {
|
}: {
|
||||||
item: ItineraryItem;
|
item: ItineraryItem;
|
||||||
badge: string;
|
badge: string;
|
||||||
@ -69,6 +62,10 @@ function DayCard({
|
|||||||
first: boolean;
|
first: boolean;
|
||||||
/** 업소 상호명 — 출발지 표시용. 일정 데이터에는 없다(프롬프트가 정거장으로 못 넣게 막는다). */
|
/** 업소 상호명 — 출발지 표시용. 일정 데이터에는 없다(프롬프트가 정거장으로 못 넣게 막는다). */
|
||||||
placeName: string;
|
placeName: string;
|
||||||
|
/** 같은 코스의 전날에서 이어지는 카드다 — 왼쪽 테두리를 지우고 앞 카드에 붙인다. */
|
||||||
|
continuesFrom?: boolean;
|
||||||
|
/** 같은 코스의 다음날로 이어진다 — 오른쪽 테두리를 점선으로 바꾼다. */
|
||||||
|
continuesTo?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const day = planDay({name: item.name, startTime, stops});
|
const day = planDay({name: item.name, startTime, stops});
|
||||||
/* ★ 기본은 시간표만. 지도와 정거장 설명은 접는다 — 카드 하나가 2.5화면을 먹고 있었고,
|
/* ★ 기본은 시간표만. 지도와 정거장 설명은 접는다 — 카드 하나가 2.5화면을 먹고 있었고,
|
||||||
@ -89,8 +86,14 @@ function DayCard({
|
|||||||
* 경계 여백이 남으면 같은 레일 안에서 카드 간격이 두 종류가 되고, 그게 더 눈에 걸린다.
|
* 경계 여백이 남으면 같은 레일 안에서 카드 간격이 두 종류가 되고, 그게 더 눈에 걸린다.
|
||||||
*/
|
*/
|
||||||
<article
|
<article
|
||||||
className="w4-paper w-[320px] shrink-0 snap-center border"
|
className={`w4-paper w-[320px] shrink-0 snap-center border-y ${
|
||||||
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}}
|
continuesFrom ? '-ml-4 border-l-0' : 'border-l'
|
||||||
|
} ${continuesTo ? '' : 'border-r'}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: ITEM_CARD,
|
||||||
|
borderColor: ITEM_BORDER,
|
||||||
|
...(continuesTo ? {borderRight: `2px dashed ${ITEM_BORDER}`} : null),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
|
className="flex items-center justify-between gap-2 border-b px-4 py-2.5"
|
||||||
@ -344,9 +347,12 @@ function DurationRail({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Rail label={`${tab} 일정`} onSelect={setSlide} onReady={onReady}>
|
<Rail label={`${tab} 일정`} onSelect={setSlide} onReady={onReady}>
|
||||||
{courses.flatMap(({item, start}) =>
|
{courses.flatMap(({item, start}) => {
|
||||||
daysOf(item).flatMap((day, dayIndex) => {
|
const days = daysOf(item);
|
||||||
const card = (
|
// ★ 같은 코스의 날짜 카드는 붙여서 한 판처럼 잇는다 — 앞 카드 오른쪽 테두리를
|
||||||
|
// 점선으로, 뒷 카드 왼쪽 테두리는 지운다(대표: "같은 일정이면 띄어놓지 말라고").
|
||||||
|
// 코스가 갈리는 경계에는 이 처리가 없어 레일 위에서 그대로 구분된다.
|
||||||
|
return days.map((day, dayIndex) => (
|
||||||
<DayCard
|
<DayCard
|
||||||
key={`${item.name}-${start}-${dayIndex}`}
|
key={`${item.name}-${start}-${dayIndex}`}
|
||||||
item={item}
|
item={item}
|
||||||
@ -355,14 +361,11 @@ function DurationRail({
|
|||||||
stops={day.stops}
|
stops={day.stops}
|
||||||
first={dayIndex === 0}
|
first={dayIndex === 0}
|
||||||
placeName={placeName}
|
placeName={placeName}
|
||||||
|
continuesFrom={dayIndex > 0}
|
||||||
|
continuesTo={dayIndex < days.length - 1}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
// ★ 같은 코스의 날짜 카드는 점선으로 잇는다(대표: "같은 일정은 붙어있게, 점선으로") —
|
})}
|
||||||
// 코스가 갈리는 경계에는 이 이음줄이 없어 레일 위에서 그대로 구분된다.
|
|
||||||
if (dayIndex === 0) return [card];
|
|
||||||
return [<DayConnector key={`${item.name}-${start}-c${dayIndex}`} />, card];
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
</Rail>
|
</Rail>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {PeopleSection} from './PeopleSection';
|
|||||||
import {ChronicleSection} from './ChronicleSection';
|
import {ChronicleSection} from './ChronicleSection';
|
||||||
import {ReadingSection} from './ReadingSection';
|
import {ReadingSection} from './ReadingSection';
|
||||||
import {PostcardSection} from './PostcardSection';
|
import {PostcardSection} from './PostcardSection';
|
||||||
import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK} from './common';
|
import {ITEM_BORDER, ITEM_INK, ITEM_INVERSE_INK, TabPanelContext} from './common';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ★ 숫자를 문장에 박지 않는다 (2026-09-04, 사장님 지적: "다섯 갈래인데 4개잖아")
|
* ★ 숫자를 문장에 박지 않는다 (2026-09-04, 사장님 지적: "다섯 갈래인데 4개잖아")
|
||||||
@ -35,11 +35,11 @@ const COUNT_WORD: Record<number, string> = {
|
|||||||
지명을 코드에 박지 않는다(`군산 읽기` 는 시연본 한 곳의 값이다). */
|
지명을 코드에 박지 않는다(`군산 읽기` 는 시연본 한 곳의 값이다). */
|
||||||
const tabsOf = (readingLabel: string) =>
|
const tabsOf = (readingLabel: string) =>
|
||||||
[
|
[
|
||||||
{id: 'songs', label: '가요 다방', Component: SongsSection},
|
{id: 'songs', label: '가요 다방', Component: SongsSection, dark: false},
|
||||||
{id: 'people', label: '인물 열전', Component: PeopleSection},
|
{id: 'people', label: '인물 열전', Component: PeopleSection, dark: true},
|
||||||
{id: 'chronicle', label: '시간의 골목', Component: ChronicleSection},
|
{id: 'chronicle', label: '시간의 골목', Component: ChronicleSection, dark: false},
|
||||||
{id: 'reading', label: readingLabel, Component: ReadingSection},
|
{id: 'reading', label: readingLabel, Component: ReadingSection, dark: false},
|
||||||
{id: 'postcard', label: '오늘의 엽서', Component: PostcardSection},
|
{id: 'postcard', label: '오늘의 엽서', Component: PostcardSection, dark: false},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function StorySection() {
|
export function StorySection() {
|
||||||
@ -66,12 +66,13 @@ export function StorySection() {
|
|||||||
color: ITEM_INK,
|
color: ITEM_INK,
|
||||||
paddingTop: 'var(--section-space)',
|
paddingTop: 'var(--section-space)',
|
||||||
/*
|
/*
|
||||||
* ★ 아래 여백을 준다 (2026-09-04, 사장님: "탭이랑 밑의 섹션 간격")
|
* ★ 아래 여백을 준다 (2026-09-04, 사장님: "탭이랑 밑의 섹션 간격") — 단, 고른 덩이가
|
||||||
* 처음엔 0 으로 두고 "다음 덩이가 제 여백을 들고 온다"고 봤다. 그런데 고른 덩이가
|
* 어두운 면(인물 열전)일 때만이다. 그 덩이는 자기 paddingBlock 위쪽까지 검은색이라,
|
||||||
* 어두운 면(인물 열전)이면 그 여백도 검은색이라, 탭 바 바로 밑에서 검은 띠가
|
* 탭 바로 밑에서 검은 띠가 칼로 자른 듯 시작한다. 밝은 덩이(가요 다방 등)는 배경이
|
||||||
* 칼로 자른 듯 시작한다. 탭과 내용은 한 벌이니 붙되, 붙어 있지는 않아야 한다.
|
* 이미 이 탭 바와 같은 --tpl-surface 라 여백을 얹으면 같은 색 빈칸만 두 겹 쌓인다
|
||||||
|
* (2026-09-18 대표: "간격 너무 넓음" — 가요 다방 탭에서 실측).
|
||||||
*/
|
*/
|
||||||
paddingBottom: 'calc(var(--section-space) * 0.55)',
|
paddingBottom: tabs[active]?.dark ? 'calc(var(--section-space) * 0.55)' : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="shell">
|
<div className="shell">
|
||||||
@ -108,11 +109,13 @@ export function StorySection() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<TabPanelContext.Provider value={true}>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<div key={tab.id} hidden={index !== active}>
|
<div key={tab.id} hidden={index !== active}>
|
||||||
<tab.Component />
|
<tab.Component />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</TabPanelContext.Provider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -29,7 +29,6 @@ it('renders only the reservation notice from guides, before booking actions, esc
|
|||||||
expect(html).toContain('<script>');
|
expect(html).toContain('<script>');
|
||||||
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('예약은 아래로');
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user