Merge branch 'feature/stay-booking'

숙박 예약 구성(요금·인원·창구) · 네이버 예약 딥링크 · 날짜/시간 목업 · 로컬 발행 함정 셋.

충돌 4건 해결:
- seo/verify.ts STRUCTURAL: main 이 unitCode·numberOfRooms 를 이미 넣었다. main 쪽을 살리고
  "사람이 읽는 unitText 는 넣지 않는다" 만 주석으로 얹었다(같은 결론에 각자 도달했다)
- pages/HomePage.tsx: import 목록만 갈렸다 — StorySection(main) · StayBookingSection(feature)
  둘 다 필요하다
- backend/collect_service.py: 테넌트 제거로 _finish 인자가 company_id → owner_user_id 로
  바뀌었다. main 시그니처를 따르고 _store_booking_link 를 그 앞에 둔다.
  place.verified_by · _add_link 시그니처는 그대로라 예약 링크 경로는 손댈 것이 없었다
- docs/DEVLOG.md: 양쪽 새 항목을 날짜 내림차순으로 합쳤다

검증: site tsc·eslint·vitest 51 passed · frontend tsc·eslint 통과 ·
백엔드 이미지 재빌드 후 collect_service import + LinkChannel.NAVER_BOOKING=7 확인.
This commit is contained in:
hbyang 2026-09-09 10:42:51 +09:00
commit 7bbeb068a8
33 changed files with 1756 additions and 61 deletions

View File

@ -1,10 +1,20 @@
# cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다. # cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다.
# 우선순위: 실제 환경변수(compose) > .env > 코드 기본값(config_models.py) # 우선순위: 실제 환경변수(compose) > .env > 코드 기본값(config_models.py)
#
# ★ 값 뒤에 주석을 붙이지 않는다. compose 의 `env_file` 은 줄 끝 주석을 **값으로 읽는다** —
# `AZURE_STORAGE_CONNECTION_STRING= # 비우면...` 은 "빈 값"이 아니라 "# 비우면..." 이라는 값이다.
# 실측(2026-09-07): 그래서 Azure 를 끈 로컬에서 발행 잡이 업로드를 시도하고
# "Connection string is either blank or malformed" 로 죽었다. 게이트는 통과했는데 발행만 실패한다.
# 주석은 반드시 **윗줄**에 둔다.
# ── 공통 solution/backend · admin/backend (server_configs 가 읽는다) # ── 공통 solution/backend · admin/backend (server_configs 가 읽는다)
APP_ENV=local APP_ENV=local
DB_HOST=127.0.0.1 # ★ compose 로 띄우면 `host.docker.internal` 이다 — 컨테이너 안의 127.0.0.1 은 그 컨테이너다.
# 127.0.0.1 은 백엔드를 **네이티브로**(.venv/bin/python) 돌릴 때만 맞다.
# 이 값을 그대로 두고 `docker compose up` 하면 API 는 healthz 200 으로 멀쩡해 보이는데
# 워커만 조용히 재시작을 반복한다(ConnectionRefusedError 5432) — 발행 잡이 영원히 안 돈다.
DB_HOST=host.docker.internal
DB_PORT=5432 DB_PORT=5432
DB_USER=postgres DB_USER=postgres
DB_PASSWORD= DB_PASSWORD=
@ -17,12 +27,15 @@ JWT_REFRESH_SECRET=
# 키가 비면 그 어댑터만 꺼진다. 서버는 뜬다. # 키가 비면 그 어댑터만 꺼진다. 서버는 뜬다.
PERPLEXITY_API_KEY= PERPLEXITY_API_KEY=
COLLECT_USE_PERPLEXITY=0 # Perplexity 채널 발견. 0=끔(기본) # Perplexity 채널 발견. 0=끔(기본)
COLLECT_USE_PERPLEXITY=0
NAVER_CLIENT_ID= NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET= NAVER_CLIENT_SECRET=
KAKAO_REST_API_KEY= # 미발급. 없으면 네이버 지역검색을 쓴다 # 미발급. 없으면 네이버 지역검색을 쓴다
KAKAO_REST_API_KEY=
GEMINI_API_KEY= GEMINI_API_KEY=
TOUR_API_KEY= # 디코딩된 키(인코딩 키는 이중 인코딩된다) # 디코딩된 키(인코딩 키는 이중 인코딩된다)
TOUR_API_KEY=
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다). # 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션) # Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
@ -39,15 +52,23 @@ GOOGLE_CLIENT_ID=
# ── solution/frontend 브라우저가 부르는 주소 (compose 가 VITE_* 로 주입) # ── solution/frontend 브라우저가 부르는 주소 (compose 가 VITE_* 로 주입)
# ★ 브라우저가 부르는 주소다. 서버에 올리면 localhost 는 즉시 틀린다. # ★ 브라우저가 부르는 주소다. 서버에 올리면 localhost 는 즉시 틀린다.
# PUBLIC_API_BASE_URL=http://localhost:9800 # ★ **앱과 같은 오리진을 적는다.** nginx(:80)가 /v1 을 같은 오리진으로 프록시하므로
# PUBLIC_WEB_BASE_URL=http://localhost:3000 # (nginx/site.conf) 앱이 부를 주소는 `:9800` 이 아니라 앱 주소 그 자체다. `:9800` 을 적으면
# 스스로 크로스 오리진을 만들어 CORS 가 붙고, 화면은 뜨는데 **로그인만 계속 실패한다** —
# 서버는 200 에 토큰까지 내려보내고 브라우저가 allow-origin 이 없어 그 응답을 버린다.
# 실측(2026-09-07): 이 기본값 그대로 띄우면 :80 으로 연 앱에서 로그인이 안 된다.
# ★ 값을 바꾸면 번들을 다시 구워야 한다: ./deploy.sh solution-site
PUBLIC_API_BASE_URL=http://localhost
PUBLIC_WEB_BASE_URL=http://localhost
# ── solution/site 발행물 — solution/backend 도 같이 본다 # ── solution/site 발행물 — solution/backend 도 같이 본다
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다. # canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
# 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다. # 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai # SITE_PUBLIC_HOST=web4ai.o2osolution.ai
INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상) # 비우면 색인 통보를 건너뛴다(발행은 정상)
AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다 INDEXNOW_KEY=
# 비우면 로컬 발행만 한다
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER= AZURE_STORAGE_CONTAINER=
AZURE_STORAGE_PREFIX= AZURE_STORAGE_PREFIX=

View File

@ -260,7 +260,9 @@ services:
args: args:
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다 # ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
# → 주소를 바꿨으면 `./deploy.sh solution-site`. # → 주소를 바꿨으면 `./deploy.sh solution-site`.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} # ★ 기본값이 앱과 **같은 오리진**이다. nginx 가 /v1 을 프록시하므로 :9800 을 박으면
# 스스로 크로스 오리진을 만들어 로그인만 조용히 실패한다(위 주석 · .env.example).
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost} VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost} VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다. # ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.

View File

@ -5,6 +5,55 @@
--- ---
## 2026-09-09 — 예약 안내 안에 날짜·시간 목업을 넣는다 (연동 없음)
**무슨 일** — 예약 흐름을 화면으로 보기 위해 `StayBookingDemo` 를 예약 안내 섹션 안에 넣었다.
날짜(2주) · 도착 시간 · 객실 · 인원을 고르면 확인 화면이 나오고, 거기서 전화로 잇는다.
**어디에도 연동하지 않는다** — 재고 조회도 접수도 결제도 없다(PRODUCT.md 6절은 그대로다).
**목업이라도 지킨 선**
- **"마감/잔여" 를 만들지 않는다.** 우리는 그 값을 모른다. 그럴듯하게 지어내면 목업이 아니라
거짓말이고, 손님은 그 표시를 보고 다른 날을 고른다
- **시간 후보를 임의로 늘어놓지 않는다.** 체크인 fact(16:00)에서 시작해 5칸을 만든다 —
fact 가 없으면 시간 선택을 아예 내지 않는다. 확인된 값과 어긋나는 선택지는 만들지 않는다
- **요금은 요금표·JSON-LD 와 같은 출처**(`unitBaseRate`)를 쓴다. 데모라고 다른 숫자를 보이면
같은 페이지가 두 값을 말하게 된다
- 확인 화면은 "접수됐다" 고 쓰지 않는다 — 어디에도 보내지 않으므로 사실이 아니다.
반대로 "접수되지 않았다" 는 경고도 두지 않는다(2026-09-09 결정: 흐름을 보는 화면이라
경고문이 흐름을 가린다). **선택 내용 확인**까지만 말하고 전화로 잇는다
**★ 날짜는 브라우저에서 만든다 (mounted 게이트)**
프리렌더가 서버에서 날짜를 구우면 **발행 시각의 날짜가 정적 HTML 에 박힌다.** 한 달 뒤
크롤러가 그 페이지를 읽으면 지난 날짜가 예약 가능일로 적혀 있다 — 화면은 멀쩡한데 기계가
읽는 값만 틀리는, 이 레포가 가장 자주 밟은 종류다. 그래서 서버 렌더에서는 달력을 그리지 않고
안내 한 줄만 내보내고, 달력은 하이드레이션 후에 그린다. 자바스크립트가 꺼진 크롤러가 보는
것은 "실제 예약 가능 여부와 결제는 아래 예약 창구에서" 뿐이다.
**구조화 데이터는 건드리지 않았다.** 데모는 JSON-LD 에도 llms.txt 에도 나가지 않는다 —
`makesOffer.availability` 는 여전히 없고(빈 방을 모른다), llms.txt 는 "이 홈페이지는 빈 방
재고와 결제를 처리하지 않습니다" 를 그대로 말한다. 목업을 AI 에게 예약 창구로 소개하면
그때부터는 목업이 아니다.
**연동을 붙일 자리** — `ConfirmPanel` 한 곳이다. 실시간 재고·접수가 생기면 그 함수만 바뀐다.
**빌더 캔버스도 같이 맞췄다** — 사장님 편집 화면은 여전히 "네이버 실시간 온라인 예약 /
캘린더에서 바로 확정 예약" 을 그리고 있었다. 우리는 실시간 예약을 하지 않는데다,
**에디터에서 본 것과 발행된 사이트가 서로 다른 물건**이었다.
- `booking/BookingCard`: 발행본 구성(날짜 칩 · 도착 시간 · 인원 · 예약 요청 · 전화 창구)의
미리보기로 갈아엎었다. 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라 상태를 두지 않고
첫 칸이 골라진 모습으로 고정한다. 시간 칸은 발행본과 같은 규칙으로 **체크인 fact 가 있을
때만** 그린다
- `booking/BookingBanner` "실시간 캘린더" → "날짜와 시간을 고르고 예약 창구로 이어집니다",
`rooms/RoomCard` "실시간 예약 신청" → "예약 안내 보기", `hero/HeroEditorial` "실시간 예약"
→ "예약 안내"
- `LinkChannel.NAVER_BOOKING` 을 orval 생성물에 반영. ★ `npm run orval` 을 그대로 돌리면
**141파일 6,400줄**이 바뀐다 — 전부 따옴표·줄바꿈 포매팅 드리프트고 스펙 변경은 enum
한 줄뿐이다. 그래서 생성물을 되돌리고 그 한 줄만 남겼다(실측 2026-09-09)
**검증** — `tsc·eslint` 통과, `vitest` 51 passed(신규 4건: 날짜가 HTML 에 안 박히는지 ·
JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후
`/s/<slug>` 에서 데모 껍데기와 안내 문구 확인.
## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다 ## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다
**무슨 일** **무슨 일**
@ -111,6 +160,140 @@ False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음
건드려야 해서 이번 변경에 섞지 않았다. 건드려야 해서 이번 변경에 섞지 않았다.
--- ---
## 2026-09-08 — "예약" 을 누르면 검색 화면이 떴다 — 네이버 예약 주소를 수집해서 쓴다
**무슨 일**
발행본의 예약 버튼이 네이버 **플레이스** 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번
더 눌러야 하고, 자동 발견이 물어온 URL 이 `map.naver.com/p/search/…`(검색 결과 주소)인 사장님은
**예약하려고 눌렀는데 검색 화면**을 봤다. 예약하러 온 손님은 거기서 끝난다.
**근거 — 주소를 지어내지 않아도 된다**
플레이스 모바일 응답(`__APOLLO_STATE__`)의 `ROOT_QUERY.placeDetail(...).naverBooking` 에
네이버가 예약 주소를 직접 준다(실측 2026-09-08, place 1273971279):
naverBookingUrl : "https://m.booking.naver.com/booking/6/bizes/1067685"
tabs : [home, feed, menu, booking(예약), review, …]
★ `bookingBusinessId`(1067685)와 `businessTypeId`(6)로 주소를 **조립하지 않는다.** 조립하면
예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌러서 빈 화면을 본 손님은 그 가게가 예약을
안 받는 줄로 읽는다. 응답이 `naverBookingUrl` 을 줄 때만 준 그대로 쓴다(미사용 업소는 null).
**바꾼 것**
- `LinkChannel.NAVER_BOOKING = 7` (백엔드 enum · shared enum · init.sql 주석). 플레이스와 가른
이유는 성격이 다르기 때문이다 — 이건 **예약 화면 그 자체**다
- `collector/base.py`: `RawSource.booking_url` — 채널이 스스로 알려준 예약 주소를 싣는 자리
- `naver_place_adapter._booking_url()`: 위 노드에서 읽는다. 키에 질의 인자가 통째로 박혀 있어
(`placeDetail({"input":…})`) 이름으로 못 찾으므로 접두사로 찾는다
- `collect_service._store_booking_link()`: 예약 채널 링크로 등록하고 **자동 확정**한다.
근거는 `discover_naver_place` 와 같다 — 이미 확정된 플레이스가 자기 예약 주소로 내놓은
값이라 남의 가게가 섞일 경로가 없다. 여기서 클릭을 한 번 더 받으면 그 사이 예약 버튼은
계속 검색 화면으로 간다
- `site/seo/jsonld.ts` `BOOKING_CHANNELS`: **순서가 우선순위**가 됐다(네이버 예약 → 야놀자 →
여기어때 → 플레이스). `bookingChannelUrl` 이 이 순서로 고르므로 화면 버튼과
`makesOffer.url`·`potentialAction` 이 같은 곳을 가리킨다
- `site/lib/derive.ts`: 예약 버튼을 같은 순서로 정렬하고, **검색 결과 주소는 뺀다** —
예약하러 온 사람에게 검색 화면을 주는 건 링크가 없는 것보다 나쁘다. 링크가 하나도 없으면
"온라인 예약 채널은 등록되지 않았습니다" 로 전화만 남는다는 것을 말해 준다
- `bookingCtaLabel()`: `${채널}에서 예약` 을 일괄로 쓰면 "네이버 예약에서 예약" 이 된다.
그리고 이 채널만 누르는 즉시 예약 화면이므로 버튼이 그 차이를 말해야 한다 —
"네이버 예약으로 바로 예약하기"
- 빌더도 이 채널을 안다(`useCollectFlow` 라벨, `ChannelUrlInput` 의 호스트 판정)
**검증** — 실제 네이버 응답으로 어댑터 확인: `RawSource.booking_url =
https://m.booking.naver.com/booking/6/bizes/1067685` · 예약 노드가 없는 응답에서는 None.
`tsc·eslint` 통과, `vitest` 47 passed(신규 4건: 채널 우선순위 · 버튼 문구 · JSON-LD 대상 ·
검색 URL 배제).
## 2026-09-07 — `.env.example` 그대로 쓰면 로컬 발행이 안 됐다 — 함정 둘
클론 직후 문서대로 `cp .env.example .env` 하고 `docker compose up -d` 한 다음 발행을 걸어 봤다.
**게이트는 통과하는데 발행만 실패한다.** 두 가지가 겹쳐 있었다.
**1) `DB_HOST=127.0.0.1`** — 컨테이너 안의 127.0.0.1 은 그 컨테이너다. compose 기본값은
`host.docker.internal` 인데 `.env` 가 그걸 덮어쓴다. 증상이 고약하다: API 는 `/healthz` 가
DB 를 안 보므로 **200 healthy** 로 뜨고, **워커만 조용히 재시작을 반복한다** — 화면은 멀쩡하고
발행 잡만 영원히 안 돈다.
**2) 줄 끝 주석이 값이 된다.** compose 의 `env_file` 은 `KEY= # 설명` 을 "빈 값"으로 읽지
않는다 — 값이 `"# 설명"` 이다. 그래서 Azure 를 끈 로컬에서 `is_configured()` 가 참이 되고
발행 잡이 업로드를 시도해 `Connection string is either blank or malformed` 로 죽었다.
같은 모양이 5개였다: `COLLECT_USE_PERPLEXITY`(값 `0` 이 `"0 # ..."` 가 된다) ·
`KAKAO_REST_API_KEY` · `TOUR_API_KEY` · `INDEXNOW_KEY` · `AZURE_STORAGE_CONNECTION_STRING`.
**3) 앱이 스스로 크로스 오리진을 만든다.** `nginx/site.conf` 는 `/v1` 을 같은 오리진으로
프록시하고 주석에도 "앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다" 고 적혀
있는데, compose 의 빌드 인자 기본값이 `VITE_API_BASE_URL=http://localhost:9800` 이었다.
`:80` 으로 앱을 열면 번들이 `:9800` 을 부르므로 크로스 오리진이 되고, `CLIENT_URL` 기본값
(3000~3005)에 `http://localhost` 가 없어 **로그인만 계속 실패한다.** 증상이 사람을 속인다 —
서버는 200 에 토큰까지 내려보내고, 브라우저가 `allow-origin` 이 없어 그 응답을 버리므로
화면에는 "로그인에 실패했습니다" 만 뜬다. 비밀번호를 의심하게 된다.
**고친 것**
- `.env.example`: `DB_HOST` 기본값을 `host.docker.internal` 로. 값 뒤 주석은 전부 **윗줄로**
올리고, 파일 머리에 "값 뒤에 주석을 붙이지 않는다" 를 근거와 함께 박았다
- `.env.example` · `docker-compose.yml`: 앱이 부르는 API 주소 기본값을 **앱과 같은 오리진**
(`http://localhost`)으로. CORS 를 허용해서 뚫는 게 아니라 **크로스 오리진을 만들지 않는다** —
nginx 가 이미 같은 오리진으로 프록시하고 있었다. `PUBLIC_API_BASE_URL` 을 주석이 아니라
값으로 내놨다(주석으로 두면 compose 기본값이 이기고, 그 기본값이 문제였다)
**검증** — 새 DB(`web4ai_db`)에 `init.sql` 적용 → `docker compose down -v` 후 `up -d --build` →
번들에 `localhost:9800` 참조 0건 · `POST http://localhost/v1/auth/login` 200(프리플라이트 없음) ·
프리렌더가 기동하며 payload 2건 재굽기 → `/` `/s/` `/s/<slug>` 전부 200. 그리고 →
`scripts/demo_build.py` 로 발행: 게이트 통과 · `published: true` · 프리렌더가 굽고
`http://localhost/s/<slug>` 200. ★ 참고로 `demo_build.py` 는 자기 안에서 워커를 한 번 돌리는데,
compose 워커가 잡을 먼저 집어가므로 **스크립트 출력은 "게이트 거부"로 보인다** — 실제 결과는
`job.jobs.result` 와 워커 로그에 있다.
## 2026-09-07 — 숙박 예약 구성 — "실시간 예약" 섹션이 전화번호 한 줄이었다
**왜**
숙박으로 발행하면 서버 기본표(`site_payload._DEFAULT_THEME`)가 `booking` 섹션을 켠다. 그런데
발행본의 `BookingSection` 이 읽는 fact 는 `reservation_required`·`reservation_channel` 두 개이고,
**둘 다 숙박 스키마(`lodging.json`)에 없다.** 그래서 펜션·민박 페이지의 "실시간 예약" 섹션에는
전화번호 한 줄만 남았다 — 요금도, 인원도, 취소 규정도, 예약 창구도 없었다. 숙박은 예약이 곧
매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분인데, 그 답의 근거가
페이지에 없으면 AI 는 OTA 후기에서 추측한다.
★ **예약을 처리하게 만든 게 아니다.** 빈 방 재고도 결제도 갖지 않는다([PRODUCT.md 6절](PRODUCT.md)
— "사이트는 예약 채널로 보낸다"). 날짜 선택기·예약 폼을 그리지 않았다 — 없는 기능을 화면으로
흉내내면 손님은 예약한 줄 알고 안 오고, 그 전화는 사장님이 받는다. 대신 **예약에 필요한 사실 +
실제로 예약이 되는 창구**를 한자리에 모았고, "여기서 결제되지 않는다"를 화면 맨 앞과 llms.txt 에
명시했다.
**바꾼 것**
- `site/src/sections/StayBookingSection.tsx` (신규) — 객실별 요금·인원 / 예약 창구(전화 + 확정
채널) / 예약 전 확인(체크인·체크아웃·취소환불·추가인원·프런트 시간·취사·반려동물·흡연).
근거가 하나도 없으면 섹션째 안 나간다
- `site/src/lib/derive.ts` — `stayBookingView()` 가 **그릴지 말지까지** 판단한다. 상단 내비·하단
탭이 같은 함수를 본다 — 세 곳이 각자 판단하면 눌러도 아무 일 없는 "예약" 탭이 생긴다.
예약 창구로 나가는 채널은 문의 목록에서 뺀다(네이버 플레이스가 두 번 보였다)
- `site/src/seo/jsonld.ts` — `unitBaseRate()` 를 **요금 숫자의 단일 출처**로 만들고 화면과
`makesOffer.price` 가 같이 쓴다(각자 계산하면 절대규칙 3 위반으로 발행이 멈춘다).
`makesOffer`(객실별 1박 요금) · `potentialAction: ReserveAction`(확정 채널만) 추가.
**`availability` 는 넣지 않았다** — 빈 방을 모르는데 InStock 을 주장하면 그게 거짓이다
- `site/src/seo/llms.ts` — 숙박 `## 예약` 블록. LLM 은 위에서부터 읽는다. 예약 경로가 "공식 채널"
절 맨 아래에만 있으면 답에 안 실린다
- `frontend/src/data/industryData.ts` · `backend/services/site_payload.py` — 숙박 기본 섹션 이름을
**"실시간 예약" → "예약 안내"**. 실시간 예약을 하지 않는데 제목이 그렇게 말하고 있었다.
두 파일은 `tests/test_site_theme.py` 가 1:1 로 묶어 두므로 같이 고쳤다
- 데모 fixture 의 theme 에 `rules`·`booking` 을 넣었다 — 서버 기본표에는 있는데 fixture 에만
없어서, 개발 서버로는 이 두 섹션을 아예 볼 수 없었다
**곁에서 나온 것 — 데모 payload 는 원래 굽히지 않았다**
`npm run prerender`(payload 미지정 = 데모)가 **절대규칙 3 대조 9건으로 실패**하고 있었다.
내 변경 전에도 같은 건수로 실패했다(main 에서 재현 확인).
1. `verify.ts` 가 URL 을 **원본 HTML 문자열**에서 찾았다. 속성으로 나갈 때 `&` 가 `&amp;` 로
이스케이프되므로 쿼리스트링 있는 이미지 URL 은 **화면에 있는데도** 절대 안 찾아진다.
→ 엔티티를 되돌린 사본에서도 찾아본다. 표기 차이는 거짓이 아니다(숫자 `asShown()` 과 같은 이유).
되돌린 사본에서도 못 찾으면 그대로 실패다 — 느슨해지지 않았다.
2. `unitCode: 'MTK'`(㎡ 의 UN/CEFACT 코드)를 본문에서 찾고 있었다. 한국어 페이지에 'MTK' 가
찍힐 일은 없다 — `priceCurrency`('KRW')와 같은 종류의 메타값이라 `STRUCTURAL` 로 옮겼다.
★ 사람이 읽는 `unitText` 는 옮기지 않았다 — 그건 화면에 있어야 하는 말이다.
**검증** — `tsc·eslint` 통과, `vitest` 43 passed(신규 21건: 예약 뷰·발행 HTML·JSON-LD 대조·llms.txt).
데모 payload 재굽기 성공(1개 중 1개) → `npm run serve` 로 `/s/moonlight-stay-jeju` 200 확인.
백엔드 pytest 는 이 환경에 venv 가 없어 못 돌렸다 — 에디터↔서버 섹션표 parity 는 그 테스트와
같은 방식으로 손으로 대조했다(stay: `예약 안내` 양쪽 일치).
## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 ## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다

View File

@ -105,7 +105,7 @@ CREATE TABLE IF NOT EXISTS place.place_aliases (
CREATE TABLE IF NOT EXISTS place.place_links ( CREATE TABLE IF NOT EXISTS place.place_links (
link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 링크 식별자(PK) link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 링크 식별자(PK)
place_id uuid NOT NULL, -- 사업장(place.places.place_id) place_id uuid NOT NULL, -- 사업장(place.places.place_id)
channel SMALLINT NOT NULL, -- 채널(LinkChannel): 1=야놀자 2=여기어때 3=네이버플레이스 4=인스타 5=공식홈 6=블로그 99=기타 channel SMALLINT NOT NULL, -- 채널(LinkChannel): 1=야놀자 2=여기어때 3=네이버플레이스 4=인스타 5=공식홈 6=블로그 7=네이버예약 99=기타
url VARCHAR(1000) NOT NULL, -- 발견된 URL url VARCHAR(1000) NOT NULL, -- 발견된 URL
title VARCHAR(300) NULL, -- 제목/스니펫 title VARCHAR(300) NULL, -- 제목/스니펫
discovered_by SMALLINT NOT NULL, -- 발견 주체(SourceType): 2=api(Perplexity) 1=owner(직접) discovered_by SMALLINT NOT NULL, -- 발견 주체(SourceType): 2=api(Perplexity) 1=owner(직접)

View File

@ -267,6 +267,11 @@ class LinkChannel(CodeEnum):
INSTAGRAM = 4 INSTAGRAM = 4
OFFICIAL_SITE = 5 # 사장님 자체 홈페이지 OFFICIAL_SITE = 5 # 사장님 자체 홈페이지
BLOG = 6 BLOG = 6
# ★ 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다.
# 플레이스 홈은 예약 버튼을 한 번 더 눌러야 하고, 자동 발견이 검색 URL 을 물어온
# 경우에는 아예 검색 결과가 뜬다 — 발행본의 "예약" 버튼이 그리로 가면 손님은
# 예약을 포기한다. 주소는 지어내지 않는다: 플레이스 응답의 naverBookingUrl 그대로다.
NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com)
ETC = 99 ETC = 99

View File

@ -521,6 +521,8 @@ async def run_collect(job: dict) -> dict:
), ),
) )
await _store_booking_link(place, place_id, source)
unit_map = await ensure_units(place_id, [source]) unit_map = await ensure_units(place_id, [source])
unit_total = max(unit_total, len(unit_map)) unit_total = max(unit_total, len(unit_map))
f = await store_facts(actor, place_id, [source], unit_map) f = await store_facts(actor, place_id, [source], unit_map)
@ -548,6 +550,40 @@ async def run_collect(job: dict) -> dict:
return result return result
async def _store_booking_link(place, place_id: str, source) -> bool:
"""수집 중 채널이 알려준 예약 주소를 **예약 채널 링크**로 남긴다.
★ 왜 필요한가 (실측 2026-09-08)
발행본의 "예약" 버튼이 네이버 플레이스 링크를 그대로 열었다. 그 링크는 잘해야 플레이스
홈이라 예약까지 한 번 더 눌러야 하고, 자동 발견이 검색 URL(`map.naver.com/p/search/…`)을
물어온 경우에는 **검색 결과 화면**이 뜬다. 예약하려고 누른 손님이 검색 결과를 만나면
거기서 끝난다.
★ 주소를 만들지 않는다. 플레이스 응답의 `naverBookingUrl` 을 그대로 쓴다
(naver_place_adapter._booking_url 머리주석). 예약을 받지 않는 업소에는 이 값이 없고,
없으면 링크도 없다 — 없는 예약 창구를 만들어내지 않는다.
★ 자동 확정한다. 근거는 `discover_naver_place` 와 같다 — 이 URL 은 **이미 확정된**
플레이스 페이지가 자기 예약 주소로 내놓은 값이라, 남의 가게가 섞일 경로가 없다.
여기서 클릭을 한 번 더 받으면 사장님이 확정을 안 한 사이트는 예약 버튼이 계속
검색 화면으로 간다.
"""
url = (getattr(source, "booking_url", None) or "").strip()
if not url:
return False
added = await _add_link(
place_id, LinkChannel.NAVER_BOOKING, url,
f"{place.name} 네이버 예약", SourceType.CRAWL,
)
await DB_SESSION_MNG.execute_lambda_claim(
place_links.DBType(),
lambda s: _place_crud.confirm_link_by_url(s, uuid.UUID(place_id), url, place.verified_by, GTime.UTC()),
)
LOG.i(f"[collect] 네이버 예약 링크 {'등록·확정' if added else '확정'} — {url}")
return added
async def _finish(place_id: str, owner_user_id: str, status: PlaceStatus): async def _finish(place_id: str, owner_user_id: str, status: PlaceStatus):
"""수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다.""" """수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다."""
await DB_SESSION_MNG.execute_lambda_claim( await DB_SESSION_MNG.execute_lambda_claim(

View File

@ -99,6 +99,10 @@ class RawSource:
text: Optional[str] = None # 태그 걷어낸 본문 text: Optional[str] = None # 태그 걷어낸 본문
facts: list[CollectedFact] = field(default_factory=list) facts: list[CollectedFact] = field(default_factory=list)
media: list[CollectedMedia] = field(default_factory=list) media: list[CollectedMedia] = field(default_factory=list)
# ★ 수집 중 **그 채널이 스스로 알려준** 예약 주소. 우리가 만든 주소가 아니다.
# 네이버 플레이스 응답의 naverBookingUrl 이 여기 실린다 — 발행본의 "예약" 버튼이
# 플레이스 홈(한 번 더 눌러야 한다)이나 검색 결과가 아니라 예약 화면으로 바로 가게 하는 값.
booking_url: Optional[str] = None
def __post_init__(self): def __post_init__(self):
if not (self.url or "").strip(): if not (self.url or "").strip():

View File

@ -145,8 +145,12 @@ class NaverPlaceAdapter:
facts = self._to_facts(base, state) facts = self._to_facts(base, state)
media = self._to_media(state) media = self._to_media(state)
booking_url = self._booking_url(state)
LOG.i(f"[naver_place] {base.get('name')} — fact {len(facts)}건 · 사진 {len(media)}장 (id={place_id})") LOG.i(
f"[naver_place] {base.get('name')} — fact {len(facts)}건 · 사진 {len(media)}장"
f"{' · 예약 주소 있음' if booking_url else ''} (id={place_id})"
)
return RawSource( return RawSource(
url=url, url=url,
adapter_id=self.id, adapter_id=self.id,
@ -161,9 +165,36 @@ class NaverPlaceAdapter:
), ),
facts=facts, facts=facts,
media=media, media=media,
booking_url=booking_url,
) )
# ---- 내부 ---------------------------------------------------------- # ---- 내부 ----------------------------------------------------------
@staticmethod
def _booking_url(state: dict) -> Optional[str]:
"""네이버 예약 화면 주소. **네이버가 준 값 그대로**다 — 조립하지 않는다.
★ 왜 조립하지 않나
응답에는 `bookingBusinessId`(1067685)와 `businessTypeId`(6)가 같이 있어서
`m.booking.naver.com/booking/{type}/bizes/{id}` 를 만들 수 있을 것처럼 보인다.
그러면 예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌렀는데 빈 화면이
나오면 손님은 그 가게가 예약을 안 받는 줄로 읽는다. 응답이 `naverBookingUrl` 을
줄 때만, 준 그대로 쓴다. 없으면 없는 것이다(실측: 예약 미사용 업소는 null).
★ 값은 ROOT_QUERY 의 placeDetail 응답 안에 있다. 키에 질의 인자가 통째로 박혀 있어
(`placeDetail({"input":{...}})`) 이름으로 못 찾는다 — 접두사로 찾는다.
"""
root = state.get("ROOT_QUERY")
if not isinstance(root, dict):
return None
detail = next(
(v for k, v in root.items() if k.startswith("placeDetail") and isinstance(v, dict)), None
)
booking = (detail or {}).get("naverBooking")
if not isinstance(booking, dict):
return None
url = str(booking.get("naverBookingUrl") or "").strip()
return url or None
async def _resolve_place_id(self, url: str) -> str: async def _resolve_place_id(self, url: str) -> str:
"""URL 에서 place id 를 뽑는다. """URL 에서 place id 를 뽑는다.

View File

@ -86,7 +86,7 @@ _DEFAULT_THEME = {
"card": "#fafafa", "text": "#09090b", "accent": "#2563eb"}, "card": "#fafafa", "text": "#09090b", "accent": "#2563eb"},
"sections": [ "sections": [
("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False), ("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False),
("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "실시간 예약", False), ("info", "기본 정보", True), ("rules", "이용 규정", False), ("booking", "예약 안내", False),
("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False), ("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("weather", "날씨", False),
("local", "지역 정보", False), ("faq", "자주 묻는 질문", False), ("local", "지역 정보", False), ("faq", "자주 묻는 질문", False),
], ],

View File

@ -19,5 +19,6 @@ export const LinkChannel = {
INSTAGRAM: 4, INSTAGRAM: 4,
OFFICIAL_SITE: 5, OFFICIAL_SITE: 5,
BLOG: 6, BLOG: 6,
NAVER_BOOKING: 7,
ETC: 99, ETC: 99,
} as const; } as const;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PlaceSearchItemNaverPlaceUrl = string | null;

View File

@ -126,7 +126,7 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' }, { id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' },
{ id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' }, { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '체크인, 주차, 시설 핵심 정보' },
{ id: 'rules', type: 'rules', name: '이용 규정', isLocked: false, isEnabled: true, description: '환불 규정, 입실 수칙 및 에티켓' }, { id: 'rules', type: 'rules', name: '이용 규정', isLocked: false, isEnabled: true, description: '환불 규정, 입실 수칙 및 에티켓' },
{ id: 'booking', type: 'booking', name: '실시간 예약', isLocked: false, isEnabled: true, description: '예약 현황 및 예약 신청' }, { id: 'booking', type: 'booking', name: '예약 안내', isLocked: false, isEnabled: true, description: '요금 · 예약 창구 안내' },
{ id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' }, { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '감성 인테리어와 외부 풍경' },
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' }, { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },

View File

@ -1,6 +1,10 @@
/** /**
* 예약 · 띠배너 — 브랜드 색으로 화면을 가로지르는 강한 전환 유도. * 예약 · 띠배너 — 브랜드 색으로 화면을 가로지르는 강한 전환 유도.
* 예약이 유일한 목표인 사이트에 맞는다(대신 자주 쓰면 광고처럼 보인다). * 예약이 유일한 목표인 사이트에 맞는다(대신 자주 쓰면 광고처럼 보인다).
*
* ★ 문구에서 "실시간 캘린더" 를 걷어냈다(2026-09-09). 우리는 실시간 재고를 갖지 않는다
* (PRODUCT.md 6절). 발행본이 하는 일 그대로 — 날짜·시간을 고르고 예약 창구로 잇는다 —
* 을 말한다. 에디터가 보여주는 것이 곧 발행될 것이어야 한다.
*/ */
import {Calendar} from 'lucide-react'; import {Calendar} from 'lucide-react';
import {CtaLink, SectionFrame} from '../../primitives'; import {CtaLink, SectionFrame} from '../../primitives';
@ -19,14 +23,14 @@ export function BookingBanner(props: SectionRenderProps) {
{section.name} {section.name}
</h3> </h3>
<p className="text-xs opacity-90 sm:text-sm"> <p className="text-xs opacity-90 sm:text-sm">
{section.body || section.description || `${storeName} · 실시간 캘린더에서 남은 날짜를 확인할 수 있습니다`} {section.body || section.description || `${storeName} · 날짜와 시간을 고르고 예약 창구로 이어집니다`}
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<CtaLink href={bookingHref(industryId, storeName)} variant="pill"> <CtaLink href={bookingHref(industryId, storeName)} variant="pill">
<Calendar className="size-3.5" /> <Calendar className="size-3.5" />
<span>예약 캘린더</span> <span>날짜 · 시간 고르기</span>
</CtaLink> </CtaLink>
</div> </div>
</div> </div>

View File

@ -1,34 +1,146 @@
/** /**
* 예약 · 카드 — 가운데 놓인 조용한 안내 카드. 다른 섹션의 흐름을 끊지 않는다. * 예약 · 카드 — **발행본의 예약 안내를 그대로 미리 보여준다.**
*
* ★ 왜 갈아엎었나 (2026-09-09)
* 이 카드는 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약" 을 그리고 있었다.
* 우리는 실시간 예약을 하지 않는다(PRODUCT.md 6절 — 재고도 결제도 갖지 않는다).
* 게다가 발행본은 날짜·시간을 고르는 화면인데 캔버스만 다른 문구를 보여줘서,
* 사장님이 편집 화면에서 본 것과 발행된 사이트가 서로 다른 물건이었다.
* **에디터가 보여주는 것이 곧 발행될 것**이어야 한다.
*
* ★ 여기서는 **누르지 않는다.** 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라
* 상태를 가진 위젯을 넣으면 선택과 싸운다. 첫 칸이 골라진 모습으로 고정해 두고,
* 실제 동작은 발행본(`solution/site` StayBookingDemo)이 한다.
*
* ★ 시간 칸은 **체크인 fact 가 있을 때만** 그린다. 발행본과 같은 규칙이다 —
* 체크인이 16:00 인데 미리보기가 14:00 을 보여주면 사장님은 그 선택지가 생긴 줄 안다.
*/ */
import {Calendar} from 'lucide-react'; import {CalendarDays, Clock, Minus, Phone, Plus} from 'lucide-react';
import {CtaLink, SectionBody, SectionFrame, SectionHeading} from '../../primitives'; import type {InfoField} from '@o2o/shared';
import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import {bookingHref} from '../common';
const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const;
/** 오늘부터 7칸. 캔버스는 브라우저에서만 도므로 날짜를 그대로 그려도 된다
* (발행본은 정적 HTML 이라 날짜를 굽지 않는다 — StayBookingDemo 머리주석). */
function nextDays(count: number) {
const today = new Date();
return Array.from({length: count}, (_, index) => {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + index);
return {day: date.getDate(), weekday: WEEKDAY_LABEL[date.getDay()]};
});
}
/** 확인된 값만 읽는다 — 캔버스도 발행본과 같은 규칙이다. */
function verified(fields: InfoField[], id: string): string | undefined {
const field = fields.find((f) => f.id === id);
if (!field || field.requiresVerification || !field.value?.trim()) return undefined;
return field.value.trim();
}
function arrivalSlots(checkIn?: string): string[] {
const match = /(\d{1,2})\s*:\s*(\d{2})/.exec(checkIn ?? '');
if (!match) return [];
const start = Number(match[1]);
if (!Number.isFinite(start)) return [];
return Array.from({length: 4}, (_, i) => start + i)
.filter((hour) => hour <= 23)
.map((hour) => `${String(hour).padStart(2, '0')}:${match[2]}`);
}
export function BookingCard(props: SectionRenderProps) { export function BookingCard(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, template} = props; const {section, isSelected, onSelect, template, infoFields} = props;
const days = nextDays(7);
const checkIn = verified(infoFields, 'check_in_time');
const slots = arrivalSlots(checkIn);
const phone = verified(infoFields, 'phone');
const accent = template.colors.primary;
return ( return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white"> <SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody> <SectionBody>
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} /> <SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
<div className="mx-auto max-w-lg space-y-3 rounded-xl border border-stone-200/80 bg-white p-6 text-center shadow-2xs"> <div className="mx-auto max-w-lg overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-2xs">
<h4 className="text-sm font-bold text-stone-900" style={{color: template.colors.text}}> <p className="flex items-center gap-2 border-b border-stone-200/80 px-4 py-3 text-xs font-bold text-stone-900">
네이버 실시간 온라인 예약 <CalendarDays className="size-3.5 text-stone-400" />
</h4> <span>날짜 · 시간 선택</span>
<p className="text-xs text-stone-500">
{section.body || '원하시는 날짜와 객실을 선택하여 캘린더에서 바로 확정 예약하실 수 있습니다.'}
</p> </p>
<CtaLink
href={bookingHref(industryId, storeName)} <div className="space-y-4 p-4">
variant="solid" <div>
colors={template.colors} <p className="mb-1.5 text-[11px] font-semibold text-stone-400">날짜</p>
> <ul className="flex gap-1.5 overflow-hidden">
<Calendar className="size-3.5" /> {days.map((day, index) => (
<span>네이버 예약 캘린더 열기</span> <li
</CtaLink> key={day.day}
className="flex w-11 shrink-0 flex-col items-center gap-0.5 rounded-lg border px-2 py-1.5 text-xs"
style={
index === 0
? {borderColor: accent, backgroundColor: accent, color: '#fff'}
: {borderColor: 'rgb(231 229 228)', backgroundColor: 'rgb(250 250 249)'}
}
>
<span className="text-[10px] opacity-70">{day.weekday}</span>
<span className="font-bold">{day.day}</span>
</li>
))}
</ul>
</div>
{slots.length > 0 && (
<div>
<p className="mb-1.5 flex items-center gap-1 text-[11px] font-semibold text-stone-400">
<Clock className="size-3" />
<span>도착 예정 시간 (체크인 {checkIn} 이후)</span>
</p>
<ul className="flex flex-wrap gap-1.5">
{slots.map((time, index) => (
<li
key={time}
className="rounded-md border px-2.5 py-1 text-xs"
style={
index === 0
? {borderColor: accent, backgroundColor: accent, color: '#fff'}
: {borderColor: 'rgb(231 229 228)', backgroundColor: 'rgb(250 250 249)'}
}
>
{time}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-stone-400">인원</p>
<div className="flex items-center gap-2 text-stone-500">
<span className="flex size-6 items-center justify-center rounded-md border border-stone-200">
<Minus className="size-3" />
</span>
<span className="w-9 text-center text-sm font-bold text-stone-900">2명</span>
<span className="flex size-6 items-center justify-center rounded-md border border-stone-200">
<Plus className="size-3" />
</span>
</div>
</div>
<div
className="rounded-lg px-4 py-2.5 text-center text-xs font-bold text-white"
style={{backgroundColor: accent}}
>
예약 요청 확인하기
</div>
{/* ★ 실제 예약이 되는 창구. 발행본과 같은 순서다 — 데모 다음에 진짜 창구가 온다. */}
{phone && (
<p className="flex items-center justify-center gap-1.5 text-[11px] text-stone-500">
<Phone className="size-3" />
<span>전화 예약 {phone}</span>
</p>
)}
</div>
</div> </div>
</SectionBody> </SectionBody>
</SectionFrame> </SectionFrame>

View File

@ -55,7 +55,7 @@ export function HeroEditorial(props: SectionRenderProps) {
colors={template.colors} colors={template.colors}
> >
<Calendar className="size-3.5" /> <Calendar className="size-3.5" />
<span>실시간 예약</span> <span>예약 안내</span>
</CtaLink> </CtaLink>
</div> </div>
</div> </div>

View File

@ -129,7 +129,9 @@ export function RoomCard({
const action = ( const action = (
<div className="p-4 pt-0 sm:p-5 sm:pt-0"> <div className="p-4 pt-0 sm:p-5 sm:pt-0">
<CtaLink href={bookingUrl} variant="solid" size="sm" colors={colors} block> <CtaLink href={bookingUrl} variant="solid" size="sm" colors={colors} block>
<span>실시간 예약 신청</span> {/* ★ "실시간 예약 신청" 이었다(2026-09-09 수정). 실시간 재고를 갖지 않으므로
누르면 예약이 확정되는 것처럼 읽히면 안 된다 — 발행본과 같은 말로 맞춘다. */}
<span>예약 안내 보기</span>
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
</CtaLink> </CtaLink>
</div> </div>

View File

@ -32,6 +32,9 @@ function guessChannel(url: string): {channel: LinkChannelCode; label: string} |
if (u.includes('blog.naver') || u.includes('cafe.naver')) return null; if (u.includes('blog.naver') || u.includes('cafe.naver')) return null;
return {channel: LinkChannel.NAVER_PLACE, label: '네이버 플레이스'}; return {channel: LinkChannel.NAVER_PLACE, label: '네이버 플레이스'};
} }
// 네이버 예약은 플레이스보다 먼저 본다 — 호스트가 booking.naver.com 이라 플레이스 판정에 안 걸리지만,
// 순서를 명시해 두지 않으면 나중에 판정 조건이 넓어질 때 조용히 플레이스로 흡수된다.
if (u.includes('booking.naver.com')) return {channel: LinkChannel.NAVER_BOOKING, label: '네이버 예약'};
if (u.includes('yanolja.com')) return {channel: LinkChannel.YANOLJA, label: '야놀자'}; if (u.includes('yanolja.com')) return {channel: LinkChannel.YANOLJA, label: '야놀자'};
if (u.includes('goodchoice.kr') || u.includes('yeogi.com')) { if (u.includes('goodchoice.kr') || u.includes('yeogi.com')) {
return {channel: LinkChannel.GOODCHOICE, label: '여기어때'}; return {channel: LinkChannel.GOODCHOICE, label: '여기어때'};

View File

@ -26,6 +26,7 @@ export const CHANNEL_LABEL: Record<number, string> = {
[LinkChannel.YANOLJA]: '야놀자', [LinkChannel.YANOLJA]: '야놀자',
[LinkChannel.GOODCHOICE]: '여기어때', [LinkChannel.GOODCHOICE]: '여기어때',
[LinkChannel.NAVER_PLACE]: '네이버 플레이스', [LinkChannel.NAVER_PLACE]: '네이버 플레이스',
[LinkChannel.NAVER_BOOKING]: '네이버 예약',
[LinkChannel.INSTAGRAM]: '인스타그램', [LinkChannel.INSTAGRAM]: '인스타그램',
[LinkChannel.OFFICIAL_SITE]: '공식 홈페이지', [LinkChannel.OFFICIAL_SITE]: '공식 홈페이지',
[LinkChannel.BLOG]: '블로그', [LinkChannel.BLOG]: '블로그',

View File

@ -77,6 +77,8 @@ export const LinkChannel = {
INSTAGRAM: 4, INSTAGRAM: 4,
OFFICIAL_SITE: 5, OFFICIAL_SITE: 5,
BLOG: 6, BLOG: 6,
/** 네이버 예약 화면 그 자체(m.booking.naver.com). 플레이스 홈과 가른다 — 발행본의 예약 버튼이 쓴다. */
NAVER_BOOKING: 7,
ETC: 99, ETC: 99,
} as const; } as const;
export type LinkChannel = (typeof LinkChannel)[keyof typeof LinkChannel]; export type LinkChannel = (typeof LinkChannel)[keyof typeof LinkChannel];

View File

@ -534,6 +534,11 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
{id: 'intro', name: '소개', enabled: true, locked: false}, {id: 'intro', name: '소개', enabled: true, locked: false},
{id: 'rooms', name: '객실 안내', enabled: true, locked: false}, {id: 'rooms', name: '객실 안내', enabled: true, locked: false},
{id: 'info', name: '기본 정보', enabled: true, locked: true}, {id: 'info', name: '기본 정보', enabled: true, locked: true},
// ★ 서버 기본표(`site_payload._DEFAULT_THEME`)의 숙박 목록에 있는 두 섹션이
// fixture 에는 빠져 있었다. 그래서 개발 서버로는 이용 규정·예약 안내가 보이지 않아
// "발행하면 나오는데 여기서는 안 나온다" 를 확인할 수 없었다.
{id: 'rules', name: '이용 규정', enabled: true, locked: false},
{id: 'booking', name: '예약 안내', enabled: true, locked: false},
{id: 'photos', name: '사진 갤러리', enabled: true, locked: false}, {id: 'photos', name: '사진 갤러리', enabled: true, locked: false},
{id: 'weather', name: '날씨', enabled: true, locked: false}, {id: 'weather', name: '날씨', enabled: true, locked: false},
{id: 'local', name: '지역 정보', enabled: true, locked: false}, {id: 'local', name: '지역 정보', enabled: true, locked: false},

View File

@ -12,7 +12,7 @@ import {
type SitePayload, type SitePayload,
type UnitInfo, type UnitInfo,
} from '@o2o/shared'; } from '@o2o/shared';
import {UNIT_SPEC} from '@/seo/jsonld'; import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@/seo/jsonld';
/** /**
* payload → 화면이 바로 쓰는 모양. * payload → 화면이 바로 쓰는 모양.
@ -96,12 +96,15 @@ export function unitViews(payload: SitePayload): UnitView[] {
}); });
} }
/**
* 카드에 얹는 "얼마부터".
*
* ★ 숫자를 여기서 고르지 않는다 — `unitBaseRate`(seo/jsonld.ts) 하나가 고른 값을 표기만 한다.
* 화면과 JSON-LD 가 각자 계산하면 어긋날 수 있고, 어긋나면 절대규칙 3 위반으로 발행이 막힌다.
*/
function unitPriceText(unit: UnitInfo): string | undefined { function unitPriceText(unit: UnitInfo): string | undefined {
const weekday = Number(factText(unit.facts, 'weekday_price')?.replace(/[^0-9]/g, '')); const rate = unitBaseRate(unit);
const price = Number(factText(unit.facts, 'price')?.replace(/[^0-9]/g, '')); return rate ? `${rate.price.toLocaleString('ko-KR')}원부터` : undefined;
const base = Number.isFinite(weekday) && weekday > 0 ? weekday : price;
if (!Number.isFinite(base) || base <= 0) return undefined;
return `${base.toLocaleString('ko-KR')}원부터`;
} }
/** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */ /** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */
@ -269,6 +272,7 @@ export function exhibitionRows(payload: SitePayload): InfoRow[] {
/** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */ /** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */
export const CHANNEL_LABEL: Record<number, string> = { export const CHANNEL_LABEL: Record<number, string> = {
[LinkChannel.NAVER_BOOKING]: '네이버 예약',
[LinkChannel.YANOLJA]: '야놀자', [LinkChannel.YANOLJA]: '야놀자',
[LinkChannel.GOODCHOICE]: '여기어때', [LinkChannel.GOODCHOICE]: '여기어때',
[LinkChannel.NAVER_PLACE]: '네이버 플레이스', [LinkChannel.NAVER_PLACE]: '네이버 플레이스',
@ -283,17 +287,26 @@ export function channelLabel(link: ChannelLink): string {
} }
/** /**
* 예약을 실제로 받는 채널. * 예약 버튼에 찍을 말.
* *
* ★ 블로그·인스타그램은 뺀다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기" 자리에 * ★ `${channelLabel}에서 예약` 로 일괄 처리하면 네이버 예약이 "네이버 예약에서 예약" 이 된다.
* 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 뺀다 — 지금 보고 있는 이 사이트가 * 그리고 이 채널은 다른 채널과 성격이 다르다 — 누르면 **예약 화면 그 자체**가 뜬다.
* 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다. * 그 차이를 버튼이 말해 줘야 손님이 한 번 더 눌러야 하는지 아닌지를 안다.
*/
export function bookingCtaLabel(link: ChannelLink): string {
if (link.channel === LinkChannel.NAVER_BOOKING) return '네이버 예약으로 바로 예약하기';
return `${channelLabel(link)}에서 예약`;
}
/**
* 예약을 실제로 받는 채널 목록은 `seo/jsonld.ts` 의 `BOOKING_CHANNELS` 한 벌이다.
*
* ★ 블로그·인스타그램은 그 목록에 없다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기"
* 자리에 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 없다 — 지금 보고 있는 이
* 사이트가 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다.
* ★ 화면의 예약 버튼과 JSON-LD 의 `makesOffer.url`·`potentialAction` 이 **같은 링크**를
* 가리켜야 한다. 목록을 두 곳에 적으면 그게 조용히 갈라진다.
*/ */
const BOOKING_CHANNELS: readonly number[] = [
LinkChannel.YANOLJA,
LinkChannel.GOODCHOICE,
LinkChannel.NAVER_PLACE,
];
/** /**
* 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만. * 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만.
@ -308,8 +321,164 @@ function confirmedLinks(payload: SitePayload, channels: readonly number[]): Chan
return payload.links.filter((link) => link.confirmed && channels.includes(link.channel)); return payload.links.filter((link) => link.confirmed && channels.includes(link.channel));
} }
/**
* 예약 버튼에 낼 채널. **BOOKING_CHANNELS 순서대로** 정렬한다 — 예약 화면으로 바로 가는
* 채널이 맨 위 버튼이어야 한다.
*
* ★ 검색 결과 주소는 뺀다. 자동 발견이 `map.naver.com/p/search/…` 를 물어오는 경우가 있고
* (실측 2026-09-08), 그걸 "예약" 버튼에 걸면 손님이 검색 화면을 만난다 — 예약하러 온
* 사람에게 검색 결과를 주는 건 링크가 없는 것보다 나쁘다. 가게를 특정하지 못하는 주소라
* 애초에 예약 창구가 아니다.
*/
export function bookingLinks(payload: SitePayload): ChannelLink[] { export function bookingLinks(payload: SitePayload): ChannelLink[] {
return confirmedLinks(payload, BOOKING_CHANNELS); const order = new Map(BOOKING_CHANNELS.map((channel, index) => [channel, index]));
return confirmedLinks(payload, BOOKING_CHANNELS)
.filter((link) => !isSearchUrl(link.url))
.sort((a, b) => (order.get(a.channel) ?? 99) - (order.get(b.channel) ?? 99));
}
/** 가게가 아니라 **검색 결과**를 가리키는 주소인지. */
function isSearchUrl(url: string): boolean {
return /\/p\/search\/|[?&]query=/.test(url);
}
/**
* ─────────────────────────────────────────────────────────────────────────
* 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터.
* ─────────────────────────────────────────────────────────────────────────
*
* ★ 왜 숙박만 따로 만드나
* `bookingRows()` 가 읽는 `reservation_required`·`reservation_channel` 은 **숙박 스키마에
* 없는 key** 다(lodging.json 확인). 그래서 숙박으로 발행하면 서버 기본표가 "실시간 예약"
* 섹션을 켜 두는데도(`site_payload._DEFAULT_THEME`) 화면에는 전화번호 한 줄만 남았다 —
* 요금도, 인원도, 취소 규정도, 예약 창구도 없는 "예약" 섹션이었다.
* 펜션·민박은 예약이 곧 매출이고, AI 가 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 에
* 답할 근거가 이 자리에 있어야 한다.
*
* ★ 우리는 예약을 **처리하지 않는다.** 빈 방 재고도 결제도 갖지 않고(PRODUCT.md 6절),
* 확정된 예약 채널과 전화로 **보낸다.** 그래서 이 구성은 "예약 폼" 이 아니라
* **"예약에 필요한 사실 + 실제로 예약이 되는 창구"** 다. 없는 기능을 화면으로 흉내내면
* 손님은 예약한 줄 알고 안 온다.
*/
/**
* 예약 전에 반드시 확인해야 하는 fact.
*
* ★ 이용 규정(`RULE_FACT_KEYS`)과 목록이 겹친다 — 일부러다. 같은 사실이라도 손님이 그것을
* 찾는 순간이 다르다(규정은 "어떤 곳인가", 여기는 "예약을 눌러도 되는가"). 두 섹션이
* 같이 켜져 있으면 값이 두 번 보이는데, 값이 같으므로 거짓이 되지 않는다.
* ★ 프런트 운영시간을 넣는다 — 전화 예약이 1순위인 업소에서 "언제 전화하면 받나" 는
* 예약 성공 여부를 가르는 값이다.
*/
const STAY_BOOKING_NOTICE_KEYS = [
'check_in_time',
'check_out_time',
'cancel_policy',
'extra_person_fee',
'reception_hours',
'cooking_allowed',
'pet_allowed',
'smoking',
] as const;
/** 예약 창구 한 줄에 필요한 객실 정보. */
export interface StayOffer {
unitId: string;
name: string;
/** "기준 2명 · 최대 4명". 확인된 값만으로 만들고, 둘 다 없으면 undefined. */
capacityText?: string;
/** 주중·주말·성수기 요금. 확인된 것만. */
rateRows: InfoRow[];
/**
* 기준 요금 표기("주중 1박 280,000원").
*
* ★ 숫자는 `unitBaseRate`(seo/jsonld.ts)가 고른 그 값이다 — JSON-LD 의
* `makesOffer.price` 와 **같은 숫자**여야 화면 ↔ 구조화 데이터 대조를 통과한다.
*/
baseRateText?: string;
/** 객실 상세(사진·전체 스펙)는 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */
href: string;
}
function stayOffers(payload: SitePayload): StayOffer[] {
return sanitizeUnits(payload.units).map((unit) => {
const standard = factText(unit.facts, 'standard_capacity');
const max = factText(unit.facts, 'max_capacity');
const rate = unitBaseRate(unit);
return {
unitId: unit.unitId,
name: unit.name,
capacityText: [standard && `기준 ${standard}`, max && `최대 ${max}`]
.filter(Boolean)
.join(' · ') || undefined,
rateRows: (['weekday_price', 'weekend_price', 'peak_price'] as const)
.map((key) => {
const value = factText(unit.facts, key);
const label = unit.facts.find((f) => f.key === key)?.label ?? key;
return value ? {label, value} : null;
})
.filter((row): row is InfoRow => row !== null),
baseRateText: rate ? `${rate.label} ${rate.price.toLocaleString('ko-KR')}원` : undefined,
href: '#units',
};
});
}
export interface StayBookingView {
offers: StayOffer[];
notices: InfoRow[];
/** 실제로 예약이 되는 채널. 확정된 것만. */
links: ChannelLink[];
/** 말을 걸 수 있는 채널(네이버 톡톡·인스타 DM). */
contacts: ChannelLink[];
phone?: string;
}
/**
* 숙박 예약 구성에 필요한 것 전부. **근거가 하나도 없으면 null** 이다.
*
* ★ null 을 돌려주는 이유: 섹션을 그릴지 말지를 컴포넌트·상단 내비·하단 탭이 각자
* 판단하면 세 곳이 갈라진다. 눌러도 아무 일 없는 "예약" 탭은 고장으로 읽힌다.
* 판단은 이 함수 하나가 한다.
*/
export function stayBookingView(payload: SitePayload): StayBookingView | null {
if (payload.place.category !== PlaceCategory.LODGING) return null;
const view: StayBookingView = {
offers: stayOffers(payload).filter(
(offer) => offer.rateRows.length > 0 || offer.capacityText !== undefined,
),
notices: placeRowsByKeys(payload, STAY_BOOKING_NOTICE_KEYS),
links: bookingLinks(payload),
// ★ 예약 창구로 이미 나가는 채널은 문의에 다시 넣지 않는다. 네이버 플레이스는 두
// 목록에 모두 들어 있어서, 그대로 두면 같은 링크가 "예약" 과 "문의" 로 두 번 보인다 —
// 손님은 둘이 다른 곳인 줄 알고 어느 쪽을 눌러야 하는지 망설인다.
contacts: contactLinks(payload).filter(
(contact) => !bookingLinks(payload).some((link) => link.url === contact.url),
),
phone: payload.place.phone,
};
const empty =
view.offers.length === 0 &&
view.notices.length === 0 &&
view.links.length === 0 &&
view.contacts.length === 0 &&
!view.phone;
return empty ? null : view;
}
/**
* 섹션 설정에 그 섹션 자체가 있는지.
*
* ★ `isSectionEnabled()` 와 다르다 — "사장님이 껐다" 와 "payload 에 항목이 아예 없다" 는
* 다른 상태다. 항목이 없는 payload(옛 버전·손으로 만든 fixture)에서는 기본으로 내보내고,
* **명시적으로 끈 것은 존중한다.** 둘을 같이 묶으면 사장님이 끈 섹션이 되살아난다.
*/
export function hasSection(payload: SitePayload, id: string): boolean {
return payload.theme.sections.some((section) => section.id === id);
} }
export function contactLinks(payload: SitePayload): ChannelLink[] { export function contactLinks(payload: SitePayload): ChannelLink[] {

View File

@ -1,3 +1,4 @@
import {PlaceCategory} from '@o2o/shared';
import { import {
AboutSection, AboutSection,
AnswerBlock, AnswerBlock,
@ -15,11 +16,12 @@ import {
LocationSection, LocationSection,
RulesSection, RulesSection,
SpaceSection, SpaceSection,
StayBookingSection,
StorySection, StorySection,
UnitsSection, UnitsSection,
} from '@/sections'; } from '@/sections';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {isSectionEnabled} from '@/lib/derive'; import {hasSection, isSectionEnabled} from '@/lib/derive';
/** /**
* StorySection 이 탭으로 묶어 그리는 아이템. * StorySection 이 탭으로 묶어 그리는 아이템.
@ -46,6 +48,7 @@ const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', '
*/ */
export function HomePage() { export function HomePage() {
const payload = useSite(); const payload = useSite();
const isLodging = payload.place.category === PlaceCategory.LODGING;
/** /**
* 섹션 id → 발행본 컴포넌트. * 섹션 id → 발행본 컴포넌트.
@ -63,7 +66,9 @@ export function HomePage() {
menu: UnitsSection, menu: UnitsSection,
programs: UnitsSection, programs: UnitsSection,
rules: RulesSection, rules: RulesSection,
booking: BookingSection, // ★ 숙박은 예약 섹션이 다른 컴포넌트다. 같은 이름의 섹션이 업종에 따라 다른 것을
// 그리는 자리는 여기가 유일하다 — 이유는 StayBookingSection 머리주석.
booking: isLodging ? StayBookingSection : BookingSection,
space: SpaceSection, space: SpaceSection,
inquiry: InquirySection, inquiry: InquirySection,
exhibition: ExhibitionSection, exhibition: ExhibitionSection,
@ -102,6 +107,13 @@ export function HomePage() {
{/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */} {/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */}
{!isSectionEnabled(payload, 'map') && <LocationSection />} {!isSectionEnabled(payload, 'map') && <LocationSection />}
{/* ★ 숙박에서 예약 안내는 **항목이 없을 때만** 기본으로 낸다.
"사장님이 껐다" 와 "payload 에 항목이 아예 없다" 는 다른 상태다(`hasSection`) —
옛 payload·손으로 만든 fixture 에는 booking 항목이 없는데, 숙박에서 예약 창구가
없는 페이지는 이 업종 질의의 대부분("어떻게 예약해요")에 답을 못 한다.
끈 것을 되살리지는 않는다 — 그건 사장님 결정이다. */}
{isLodging && !hasSection(payload, 'booking') && <StayBookingSection />}
</main> </main>
); );
} }

View File

@ -1,6 +1,6 @@
import {ArrowUpRight, CalendarCheck, Phone} from 'lucide-react'; import {ArrowUpRight, CalendarCheck, Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {bookingLinks, bookingRows, channelLabel, sectionName} from '@/lib/derive'; import {bookingCtaLabel, bookingLinks, bookingRows, sectionName} from '@/lib/derive';
/** /**
* 예약 안내. * 예약 안내.
@ -85,7 +85,7 @@ export function BookingSection() {
className="inline-flex items-center gap-1.5 rounded-xl border border-black/10 px-4 py-2.5 text-xs font-semibold transition-colors hover:bg-black/5" className="inline-flex items-center gap-1.5 rounded-xl border border-black/10 px-4 py-2.5 text-xs font-semibold transition-colors hover:bg-black/5"
style={{backgroundColor: 'var(--color-surface-alt)'}} style={{backgroundColor: 'var(--color-surface-alt)'}}
> >
<span>{channelLabel(link)}</span> <span>{bookingCtaLabel(link)}</span>
<ArrowUpRight className="size-3.5" /> <ArrowUpRight className="size-3.5" />
</a> </a>
))} ))}

View File

@ -1,6 +1,6 @@
import {Home, Image as ImageIcon, MapPin, Sparkles} from 'lucide-react'; import {CalendarCheck, Home, Image as ImageIcon, MapPin, Sparkles} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {isSectionEnabled, unitSpec} from '@/lib/derive'; import {isSectionEnabled, stayBookingView, unitSpec} from '@/lib/derive';
import {useActiveSection} from '@/lib/use-active-section'; import {useActiveSection} from '@/lib/use-active-section';
/** /**
@ -20,6 +20,9 @@ export function MobileTabBar() {
const tabs = [ const tabs = [
{id: 'top', label: '홈', icon: Home}, {id: 'top', label: '홈', icon: Home},
...(payload.units.length > 0 ? [{id: 'units', label: spec.label, icon: ImageIcon}] : []), ...(payload.units.length > 0 ? [{id: 'units', label: spec.label, icon: ImageIcon}] : []),
...(stayBookingView(payload) !== null
? [{id: 'booking', label: '예약', icon: CalendarCheck}]
: []),
...(isSectionEnabled(payload, 'local') ? [{id: 'guide', label: '주변', icon: Sparkles}] : []), ...(isSectionEnabled(payload, 'local') ? [{id: 'guide', label: '주변', icon: Sparkles}] : []),
{id: 'location', label: '위치', icon: MapPin}, {id: 'location', label: '위치', icon: MapPin},
]; ];

View File

@ -1,6 +1,6 @@
import {Phone} from 'lucide-react'; import {Phone} from 'lucide-react';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {isSectionEnabled, unitSpec} from '@/lib/derive'; import {isSectionEnabled, stayBookingView, unitSpec} from '@/lib/derive';
/** /**
* 상단 내비. * 상단 내비.
@ -19,6 +19,9 @@ export function SiteHeader() {
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: spec.label, href: '#units', show: payload.units.length > 0},
// ★ 숙박에서 예약은 이 사이트의 목적지다. 근거(요금·창구)가 하나도 없으면 링크도 없다 —
// 눌러도 아무 일 없는 메뉴는 고장으로 읽힌다. 판단은 stayBookingView() 한 곳이 한다.
{label: '예약', href: '#booking', show: stayBookingView(payload) !== null},
{label: '주변 정보', href: '#guide', show: isSectionEnabled(payload, 'local')}, {label: '주변 정보', href: '#guide', show: isSectionEnabled(payload, 'local')},
{label: '오시는 길', href: '#location', show: true}, {label: '오시는 길', href: '#location', show: true},
{label: 'FAQ', href: '#faq', show: payload.faqs.length > 0}, {label: 'FAQ', href: '#faq', show: payload.faqs.length > 0},

View File

@ -0,0 +1,378 @@
import {useEffect, useMemo, useState} from 'react';
import {CalendarDays, Check, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-react';
import {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {unitBaseRate} from '@/seo/jsonld';
/**
* 예약 데모 — **화면 안에서만 도는 목업이다.**
*
* ★ 무엇이 아닌가
* 빈 방 재고를 조회하지 않고, 어디에도 접수하지 않으며, 결제도 없다. 네이버 예약·OTA 와
* 연동되어 있지 않다(PRODUCT.md 6절 — "사이트는 예약 채널로 보낸다. 거래를 품지 않는다").
* 흐름을 눈으로 보기 위한 구성이다.
*
* ★ 마지막 화면은 고른 내용을 확인해 주고 **진짜 창구(전화)로 잇는다.**
* "접수됐다" 고 쓰지 않는다 — 어디에도 보내지 않으므로 사실이 아니다. 반대로 "접수되지
* 않았다" 는 경고도 두지 않는다(2026-09-09 결정): 흐름을 보여주는 화면이라 경고문이
* 흐름을 가린다. 연동을 붙일 자리는 ConfirmPanel 한 곳이다.
*
* ★ 날짜는 **브라우저에서** 만든다(mounted 게이트).
* 프리렌더가 서버에서 날짜를 구우면 발행 시각의 날짜가 정적 HTML 에 박힌다 — 한 달 뒤
* 크롤러가 그 페이지를 읽으면 지난 날짜가 예약 가능일로 적혀 있다. 그건 조용히 거짓이
* 되는 종류라, 서버 렌더에서는 안내만 내보내고 달력은 하이드레이션 후에 그린다.
*
* ★ "마감/잔여" 같은 표시를 만들지 않는다. 우리는 그 값을 모른다 — 그럴듯하게 지어내면
* 목업이 아니라 거짓말이 된다. 고를 수 없는 날은 **지난 날짜**뿐이고, 그건 사실이다.
*/
/** 달력에 낼 날짜 수. 두 주면 흐름을 보기에 충분하고, 화면도 한 줄에 들어온다. */
const DAY_COUNT = 14;
const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const;
interface DayCell {
iso: string;
month: number;
day: number;
weekday: number;
/** 토·일은 주말 요금이 붙는 날이다. 요금 계산의 근거가 화면에도 보여야 한다. */
isWeekend: boolean;
}
function buildDays(from: Date): DayCell[] {
return Array.from({length: DAY_COUNT}, (_, index) => {
const date = new Date(from.getFullYear(), from.getMonth(), from.getDate() + index);
const weekday = date.getDay();
return {
iso: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`,
month: date.getMonth() + 1,
day: date.getDate(),
weekday,
isWeekend: weekday === 0 || weekday === 6,
};
});
}
/**
* 도착 시간 후보. **체크인 시간 fact 에서 시작한다.**
*
* ★ 왜 임의의 시간대를 늘어놓지 않나
* 이 숙소의 체크인이 16:00 인데 데모가 14:00 을 고르게 두면, 손님은 그 시간에 갈 수 있다고
* 읽는다. 확인된 fact 와 어긋나는 선택지는 목업이라도 만들지 않는다.
* fact 가 없으면 시간 선택 자체를 내지 않는다(추측한 시간표를 그리는 것보다 낫다).
*/
function buildArrivalSlots(checkIn?: string): string[] {
const match = /(\d{1,2})\s*:\s*(\d{2})/.exec(checkIn ?? '');
if (!match) return [];
const startHour = Number(match[1]);
const minute = match[2];
if (!Number.isFinite(startHour)) return [];
return Array.from({length: 5}, (_, index) => startHour + index)
.filter((hour) => hour <= 23)
.map((hour) => `${String(hour).padStart(2, '0')}:${minute}`);
}
interface DemoUnit {
unitId: string;
name: string;
weekdayPrice?: number;
weekendPrice?: number;
maxCapacity?: number;
}
function demoUnits(payload: SitePayload): DemoUnit[] {
return sanitizeUnits(payload.units).map((unit) => {
const num = (key: string) => {
const value = Number(factText(unit.facts, key)?.replace(/[^0-9]/g, ''));
return Number.isFinite(value) && value > 0 ? value : undefined;
};
return {
unitId: unit.unitId,
name: unit.name,
// 주중 요금은 JSON-LD·요금표와 같은 출처를 쓴다 — 데모라고 다른 숫자를 보이면 안 된다.
weekdayPrice: unitBaseRate(unit)?.price,
weekendPrice: num('weekend_price'),
maxCapacity: num('max_capacity'),
};
});
}
export function StayBookingDemo() {
const payload = useSite();
const units = useMemo(() => demoUnits(payload), [payload]);
const checkIn = useMemo(
() => selectPublishable(payload.facts).find((fact) => fact.key === 'check_in_time')?.value ?? undefined,
[payload],
);
const slots = useMemo(() => buildArrivalSlots(checkIn), [checkIn]);
/** ★ 서버 렌더에서는 false — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석). */
const [days, setDays] = useState<DayCell[] | null>(null);
useEffect(() => setDays(buildDays(new Date())), []);
const [dateIso, setDateIso] = useState<string | null>(null);
const [slot, setSlot] = useState<string | null>(null);
const [unitId, setUnitId] = useState<string | null>(units[0]?.unitId ?? null);
const [guests, setGuests] = useState(2);
const [submitted, setSubmitted] = useState(false);
const selectedDay = days?.find((day) => day.iso === dateIso) ?? null;
const selectedUnit = units.find((unit) => unit.unitId === unitId) ?? null;
const maxGuests = selectedUnit?.maxCapacity ?? 8;
const price = selectedDay && selectedUnit
? (selectedDay.isWeekend ? selectedUnit.weekendPrice ?? selectedUnit.weekdayPrice : selectedUnit.weekdayPrice)
: undefined;
const ready = Boolean(dateIso && selectedUnit && (slots.length === 0 || slot));
// 객실을 바꾸면 인원이 최대치를 넘을 수 있다 — 고른 값이 조용히 규정을 어기게 두지 않는다.
useEffect(() => {
setGuests((current) => Math.min(current, selectedUnit?.maxCapacity ?? 8));
}, [selectedUnit]);
if (units.length === 0) return null;
return (
<div
className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6"
style={{backgroundColor: 'var(--color-surface)'}}
>
<div className="border-b border-black/8 px-4 py-3 sm:px-5">
<p className="flex items-center gap-2 text-xs font-bold">
<CalendarDays className="size-3.5 opacity-50" />
<span>날짜 · 시간 선택</span>
</p>
</div>
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
{days === null ? (
<p className="px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5">
날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서
확인해 주세요.
</p>
) : submitted ? (
<ConfirmPanel
payload={payload}
onReset={() => setSubmitted(false)}
summary={[
selectedDay ? `${selectedDay.month}월 ${selectedDay.day}일(${WEEKDAY_LABEL[selectedDay.weekday]})` : null,
slot ? `도착 ${slot}` : null,
selectedUnit?.name ?? null,
`${guests}명`,
]
.filter((part): part is string => Boolean(part))
.join(' · ')}
/>
) : (
<div className="space-y-5 p-4 sm:p-5">
{/* ── 날짜 ─────────────────────────────────────── */}
<div>
<p className="mb-2 text-[11px] font-semibold opacity-55">날짜</p>
<ul className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
{days.map((day) => {
const active = day.iso === dateIso;
return (
<li key={day.iso}>
<button
type="button"
onClick={() => setDateIso(day.iso)}
aria-pressed={active}
className="flex w-13 shrink-0 flex-col items-center gap-0.5 rounded-xl border px-2 py-2 text-xs transition-colors"
style={{
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
backgroundColor: active ? 'var(--color-brand)' : 'var(--color-surface-alt)',
color: active ? '#fff' : undefined,
}}
>
<span className="text-[10px] opacity-70">{WEEKDAY_LABEL[day.weekday]}</span>
<span className="font-bold">{day.day}</span>
</button>
</li>
);
})}
</ul>
{selectedDay?.isWeekend && (
<p className="mt-1.5 text-[11px] opacity-55">주말 요금이 적용되는 날짜입니다.</p>
)}
</div>
{/* ── 도착 시간 (체크인 fact 가 있을 때만) ───────── */}
{slots.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold opacity-55">
<Clock className="size-3" />
<span>도착 예정 시간 (체크인 {checkIn} 이후)</span>
</p>
<ul className="flex flex-wrap gap-1.5">
{slots.map((time) => {
const active = time === slot;
return (
<li key={time}>
<button
type="button"
onClick={() => setSlot(time)}
aria-pressed={active}
className="rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
style={{
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
backgroundColor: active ? 'var(--color-brand)' : 'var(--color-surface-alt)',
color: active ? '#fff' : undefined,
}}
>
{time}
</button>
</li>
);
})}
</ul>
</div>
)}
{/* ── 객실 ─────────────────────────────────────── */}
<div>
<p className="mb-2 text-[11px] font-semibold opacity-55">객실</p>
<ul className="grid gap-1.5 sm:grid-cols-2">
{units.map((unit) => {
const active = unit.unitId === unitId;
return (
<li key={unit.unitId}>
<button
type="button"
onClick={() => setUnitId(unit.unitId)}
aria-pressed={active}
className="flex w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5 text-left text-xs transition-colors"
style={{
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
backgroundColor: 'var(--color-surface-alt)',
}}
>
<span className="font-semibold">{unit.name}</span>
{unit.maxCapacity && (
<span className="shrink-0 opacity-55">최대 {unit.maxCapacity}명</span>
)}
</button>
</li>
);
})}
</ul>
</div>
{/* ── 인원 ─────────────────────────────────────── */}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold opacity-55">인원</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setGuests((n) => Math.max(1, n - 1))}
aria-label="인원 줄이기"
className="flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5"
>
<Minus className="size-3.5" />
</button>
<span className="w-10 text-center text-sm font-bold">{guests}명</span>
<button
type="button"
onClick={() => setGuests((n) => Math.min(maxGuests, n + 1))}
aria-label="인원 늘리기"
className="flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5"
>
<Plus className="size-3.5" />
</button>
</div>
</div>
{/* ── 요약 · 요청 ──────────────────────────────── */}
<div className="rounded-xl border border-black/8 p-3" style={{backgroundColor: 'var(--color-surface-alt)'}}>
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs opacity-60">
{selectedDay
? `${selectedDay.month}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명`
: '날짜를 골라 주세요'}
</span>
{price != null && (
<span className="text-sm font-bold" style={{color: 'var(--color-brand)'}}>
{price.toLocaleString('ko-KR')}원
</span>
)}
</div>
{/* 요금은 확인된 요금 fact 를 그대로 읽은 값이지, 견적이 아니다. */}
{price != null && (
<p className="mt-1 text-[11px] opacity-50">
1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다.
</p>
)}
</div>
<button
type="button"
disabled={!ready}
onClick={() => setSubmitted(true)}
className="w-full rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
style={{backgroundColor: 'var(--color-brand)'}}
>
예약 요청 확인하기
</button>
</div>
)}
</div>
);
}
/**
* 확인 화면 — 고른 내용을 보여주고 예약 창구로 잇는다.
*
* ★ "접수됐다" 고 쓰지 않는다. 어디에도 보내지 않으므로 그건 사실이 아니고, 목업이라도
* 화면에 없는 일을 일어난 것처럼 적으면 그때부터는 목업이 아니라 거짓말이다.
* 반대로 "접수되지 않았다" 는 안내도 두지 않는다(2026-09-09 결정) — 흐름만 보여주는
* 화면이라 경고문이 오히려 흐름을 가린다. 그래서 **선택 내용 확인**까지만 말한다.
*/
function ConfirmPanel({
payload,
summary,
onReset,
}: {
payload: SitePayload;
summary: string;
onReset: () => void;
}) {
const phone = payload.place.phone;
return (
<div className="space-y-4 p-4 sm:p-5">
<div className="flex items-start gap-2.5">
<span
className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-white"
style={{backgroundColor: 'var(--color-brand)'}}
>
<Check className="size-4" />
</span>
<div className="min-w-0">
<p className="text-sm font-bold">예약 내용 확인</p>
<p className="mt-0.5 text-xs leading-relaxed opacity-70">{summary}</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{phone && (
<a
href={`tel:${phone}`}
className="flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)'}}
>
<Phone className="size-4" />
<span>전화로 예약하기 {phone}</span>
</a>
)}
<button
type="button"
onClick={onReset}
className="flex items-center justify-center gap-1.5 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5"
>
<RotateCcw className="size-3.5" />
<span>다시 고르기</span>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,209 @@
import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@/lib/derive';
import {StayBookingDemo} from './StayBookingDemo';
/**
* 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리.
*
* ★ 왜 `BookingSection` 과 따로 있나
* 그쪽이 읽는 fact(`reservation_required`·`reservation_channel`)는 **숙박 스키마에 없다.**
* 그래서 펜션·민박으로 발행하면 서버 기본표가 켜 두는 "실시간 예약" 섹션에 전화번호
* 한 줄만 남았다 — 요금도 인원도 취소 규정도 없는 예약 섹션이었다. 숙박은 예약이
* 곧 매출이고, "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분이다.
*
* ★ 예약을 **처리하지 않는다.** 우리는 빈 방 재고도 결제도 갖지 않는다(PRODUCT.md 6절 —
* "사이트는 예약 채널로 보낸다. 거래를 품지 않는다"). 그래서 날짜 선택기·예약 폼을
* 그리지 않는다. 없는 기능을 화면으로 흉내내면 손님은 예약한 줄 알고 안 오고,
* 그 클레임은 사장님이 받는다. 대신 **예약에 필요한 사실**과 **실제로 예약이 되는 창구**를
* 한자리에 모은다.
*
* ★ 지어낸 값이 없다. 요금·인원·취소 규정은 확인된 fact 뿐이고(`stayBookingView`),
* 예약 버튼은 확정된 채널 URL 뿐이다. 근거가 하나도 없으면 섹션째 그리지 않는다 —
* 그 판단도 `stayBookingView()` 한 곳이 한다(상단 내비·하단 탭이 같은 함수를 본다).
*
* ★ 기준 요금 숫자는 `unitBaseRate`(seo/jsonld.ts)가 고른 값이다. JSON-LD `makesOffer.price`
* 와 같은 숫자여야 절대규칙 3(화면 = 구조화 데이터) 대조를 통과한다.
*/
export function StayBookingSection() {
const payload = useSite();
const view = stayBookingView(payload);
if (!view) return null;
const {offers, notices, links, contacts, phone} = view;
return (
<section
id="booking"
aria-labelledby="booking-heading"
className="w-full border-b border-black/8 py-16 sm:py-24"
style={{backgroundColor: 'var(--color-surface-alt)'}}
>
<div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<CalendarCheck className="size-4" />
<span>Reservation</span>
</p>
<h2
id="booking-heading"
className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"
>
{sectionName(payload, 'booking', '예약 안내')}
</h2>
{/* ★ "여기서 결제되지 않는다"를 먼저 말한다. 예약 버튼을 누른 뒤에 알게 되면
손님은 속은 것으로 느끼고, 그 인상은 업소가 가져간다. */}
<p className="mb-8 max-w-2xl text-xs leading-relaxed opacity-60 sm:text-sm">
빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다. 이 페이지에서는 요금과 이용 조건만
안내합니다.
</p>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5 lg:gap-6">
{/* ── 객실별 요금 · 인원 ───────────────────────────────── */}
{offers.length > 0 && (
<div
className="overflow-hidden rounded-2xl border border-black/8 lg:col-span-3"
style={{backgroundColor: 'var(--color-surface)'}}
>
<p className="flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5">
<BedDouble className="size-3.5 opacity-50" />
<span>객실별 요금 · 인원</span>
</p>
<ul className="divide-y divide-black/5">
{offers.map((offer) => (
<li key={offer.unitId} className="p-4 sm:p-5">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h3 className="serif text-base font-bold sm:text-lg">{offer.name}</h3>
{offer.baseRateText && (
<span className="text-sm font-bold" style={{color: 'var(--color-brand)'}}>
{offer.baseRateText}
</span>
)}
</div>
{offer.capacityText && (
<p className="mt-1 text-xs opacity-60">{offer.capacityText}</p>
)}
{offer.rateRows.length > 0 && (
<dl className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{offer.rateRows.map((row) => (
<div key={row.label} className="flex items-baseline gap-1.5 text-xs">
<dt className="opacity-50">{row.label}</dt>
<dd className="font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
{/* 사진·전체 스펙은 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */}
<a
href={offer.href}
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold underline decoration-black/20 underline-offset-4 transition-opacity hover:opacity-70"
>
<span>{offer.name} 사진 · 상세 보기</span>
</a>
</li>
))}
</ul>
</div>
)}
{/* ── 예약 창구 ──────────────────────────────────────── */}
<div
className="flex h-fit flex-col gap-3 rounded-2xl border border-black/8 p-4 sm:p-5 lg:col-span-2"
style={{backgroundColor: 'var(--color-surface)'}}
>
<p className="text-xs font-bold">예약 창구</p>
{phone && (
<a
href={`tel:${phone}`}
className="flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)'}}
>
<Phone className="size-4" />
<span>전화 예약 {phone}</span>
</a>
)}
{/* ★ 확정된 채널만 나간다 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다.
눌렀는데 남의 숙소가 뜨면 그 예약은 영영 우리 것이 아니다. */}
{links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-2 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5"
style={{backgroundColor: 'var(--color-surface-alt)'}}
>
<span>{bookingCtaLabel(link)}</span>
<ArrowUpRight className="size-3.5 shrink-0" />
</a>
))}
{contacts.length > 0 && (
<div className="mt-1 border-t border-black/8 pt-3">
<p className="mb-2 text-xs opacity-50">문의</p>
<ul className="flex flex-wrap gap-2">
{contacts.map((link) => (
<li key={link.url}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1.5 text-xs transition-colors hover:bg-black/5"
>
<span>{channelLabel(link)}</span>
<ArrowUpRight className="size-3" />
</a>
</li>
))}
</ul>
</div>
)}
{links.length === 0 && (
// 채널이 하나도 확정되지 않은 업소 — 전화가 유일한 창구다. 그 사실을 말해 준다.
<p className="text-xs leading-relaxed opacity-55">
온라인 예약 채널은 등록되지 않았습니다. 예약은 전화로 문의해 주세요.
</p>
)}
</div>
</div>
{/* ── 날짜 · 시간 선택 (화면 안에서만 도는 목업) ────────── */}
<StayBookingDemo />
{/* ── 예약 전 확인 ────────────────────────────────────── */}
{notices.length > 0 && (
<div
className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6"
style={{backgroundColor: 'var(--color-surface)'}}
>
<p className="flex items-center gap-2 border-b border-black/8 px-4 py-3 text-xs font-bold sm:px-5">
<ShieldCheck className="size-3.5 opacity-50" />
<span>예약 전 확인</span>
</p>
{/* 확인된 fact 만 줄이 된다 — "확인 중" 이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다. */}
<dl className="grid grid-cols-1 divide-y divide-black/5 sm:grid-cols-2 sm:divide-y-0">
{notices.map((row) => (
<div
key={row.label}
className="flex flex-col gap-1 p-4 sm:flex-row sm:items-start sm:justify-between sm:gap-4 sm:p-5"
>
<dt className="shrink-0 text-xs opacity-55">{row.label}</dt>
<dd className="text-xs font-semibold sm:max-w-[60%] sm:text-right">{row.value}</dd>
</div>
))}
</dl>
</div>
)}
</div>
</section>
);
}

View File

@ -6,6 +6,9 @@ export {EssentialInfoSection} from './EssentialInfoSection';
export {UnitsSection} from './UnitsSection'; export {UnitsSection} from './UnitsSection';
export {RulesSection} from './RulesSection'; export {RulesSection} from './RulesSection';
export {BookingSection} from './BookingSection'; export {BookingSection} from './BookingSection';
// 숙박은 예약 구성이 통째로 다르다 — 이유는 StayBookingSection 머리주석.
export {StayBookingSection} from './StayBookingSection';
export {StayBookingDemo} from './StayBookingDemo';
export {SpaceSection} from './SpaceSection'; export {SpaceSection} from './SpaceSection';
export {InquirySection} from './InquirySection'; export {InquirySection} from './InquirySection';
export {ExhibitionSection} from './ExhibitionSection'; export {ExhibitionSection} from './ExhibitionSection';

View File

@ -0,0 +1,304 @@
/**
* 숙박 예약 구성 — 이 검사가 지키는 것.
*
* 1. 미검증 fact·확정 전 링크가 예약 화면에 새지 않는다(절대규칙 1).
* 예약은 틀리면 바로 클레임이 나는 자리다 — 확인 안 된 요금이나 남의 숙소 예약 링크가
* 한 번 나가면 손님은 헛걸음하고, 그 전화는 사장님이 받는다.
* 2. 화면의 요금과 JSON-LD 의 `makesOffer.price` 가 **같은 숫자**다(절대규칙 3).
* 갈라지면 발행 게이트가 사이트를 막는다 — 조용히 틀리는 게 아니라 발행이 멈추는 종류다.
* 3. 예약 액션이 **실제로 예약이 되는 URL** 만 가리킨다.
* 확정 전 채널을 `potentialAction` 에 넣으면 AI 가 그 주소로 손님을 보낸다.
*/
import {describe, expect, it} from 'vitest';
import {
LinkChannel,
PlaceCategory,
FactStatus,
sanitizePayloadForPublish,
type SitePayload,
} from '@o2o/shared';
import {MOONLIGHT_STAY_PAYLOAD} from '@/fixtures/moonlight-stay';
import {stayBookingView} from '@/lib/derive';
import {collectJsonLd} from '@/seo/jsonld';
import {renderLlmsTxt} from '@/seo/llms';
import {verifyJsonLd, visibleText} from '@/seo/verify';
import {render} from '@/entry-server';
import {homeMeta} from '@/seo/meta';
import {renderHead} from '@/seo/head';
const PAGE = {title: '달빛스테이 제주', description: '제주 애월 독채 펜션'};
/** 확정 전 야놀자 링크 — fixture 가 일부러 남겨 둔 값이다. */
const UNCONFIRMED_URL = 'https://www.yanolja.com/pension/0000000';
/**
* 프리렌더가 굽는 문서와 **같은 모양**으로 만든다.
*
* ★ 앱 마크업만 놓고 대조하면 안 된다 — canonical·og:image 처럼 head 로 나가는 값과
* 하이드레이션 블롭이 빠져서, 멀쩡한 값이 "화면에 없다" 로 잡힌다(실측 12건).
* 검사는 **나갈 그 HTML** 에 대고 해야 의미가 있다(verify.ts 머리주석).
* ★ 자산 경로는 이 검사와 무관하므로 고정 문자열을 쓴다.
*/
function html(input: SitePayload = MOONLIGHT_STAY_PAYLOAD): string {
const payload = sanitizePayloadForPublish(input);
const meta = homeMeta(payload);
const head = renderHead({
payload,
meta,
scriptSrc: '/assets/index.js',
cssHrefs: ['/assets/index.css'],
});
return [
'<!doctype html><html lang="ko"><head>',
head,
'</head><body>',
`<div id="root">${render(payload)}</div>`,
`<script>window.__SITE_PAYLOAD__=${JSON.stringify(payload)}</script>`,
'</body></html>',
].join('\n');
}
describe('stayBookingView — 예약에 쓸 값을 고르는 자리', () => {
it('숙박이 아니면 아무것도 돌려주지 않는다 — 다른 업종은 BookingSection 이 그린다', () => {
const cafe = {
...MOONLIGHT_STAY_PAYLOAD,
place: {...MOONLIGHT_STAY_PAYLOAD.place, category: PlaceCategory.CAFE},
};
expect(stayBookingView(cafe)).toBeNull();
});
it('확정된 예약 채널만 창구가 된다 — 확정 전 URL 은 동명 업소일 수 있다', () => {
const view = stayBookingView(MOONLIGHT_STAY_PAYLOAD)!;
expect(view.links.map((link) => link.url)).not.toContain(UNCONFIRMED_URL);
expect(view.links).toHaveLength(1); // 네이버 플레이스만 확정됨
});
it('미검증 fact 는 예약 전 확인에 오르지 않는다', () => {
const withUnverifiedPolicy = {
...MOONLIGHT_STAY_PAYLOAD,
facts: MOONLIGHT_STAY_PAYLOAD.facts.map((fact) =>
fact.key === 'cancel_policy' ? {...fact, status: FactStatus.UNVERIFIED} : fact,
),
};
const labels = stayBookingView(withUnverifiedPolicy)!.notices.map((row) => row.label);
expect(labels).not.toContain('취소·환불 규정');
// 확인된 나머지는 그대로 남는다 — 한 건이 빠졌다고 표가 사라지면 안 된다.
expect(labels).toContain('체크인 시간');
});
it('근거가 하나도 없으면 null 이다 — 눌러도 아무 일 없는 예약 메뉴를 만들지 않는다', () => {
const bare: SitePayload = {
...MOONLIGHT_STAY_PAYLOAD,
place: {...MOONLIGHT_STAY_PAYLOAD.place, phone: undefined},
facts: [],
units: [],
links: [],
};
expect(stayBookingView(bare)).toBeNull();
});
it('기준 요금 표기는 어느 요금인지 밝힌다 — 밝히지 않은 가격은 그 자체로 오해다', () => {
const offers = stayBookingView(MOONLIGHT_STAY_PAYLOAD)!.offers;
expect(offers[0].baseRateText).toBe('주중 1박 280,000원');
expect(offers[0].capacityText).toBe('기준 2명 · 최대 4명');
});
});
describe('발행 HTML — 예약 섹션이 실제로 나가는가', () => {
it('요금 · 인원 · 취소 규정 · 예약 창구가 한자리에 있다', () => {
const text = visibleText(html());
expect(text).toContain('예약 창구');
expect(text).toContain('주중 1박 280,000원');
expect(text).toContain('기준 2명 · 최대 4명');
expect(text).toContain('전화 예약 0507-1345-8821');
expect(text).toContain('네이버 플레이스에서 예약');
});
it('여기서 결제되지 않는다는 사실을 먼저 말한다 — 우리는 재고도 결제도 갖지 않는다', () => {
expect(visibleText(html())).toContain('빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다');
});
it('확정 전 예약 링크는 화면에 없다', () => {
expect(html()).not.toContain(UNCONFIRMED_URL);
});
it('booking 항목이 없는 payload 에서도 예약 안내가 나간다 — 옛 payload·fixture', () => {
const withoutBooking = {
...MOONLIGHT_STAY_PAYLOAD,
theme: {
...MOONLIGHT_STAY_PAYLOAD.theme,
sections: MOONLIGHT_STAY_PAYLOAD.theme.sections.filter((s) => s.id !== 'booking'),
},
};
expect(visibleText(html(withoutBooking))).toContain('예약 창구');
});
it('사장님이 끈 예약 섹션은 되살리지 않는다 — 껐다와 항목이 없다는 다르다', () => {
const disabled = {
...MOONLIGHT_STAY_PAYLOAD,
theme: {
...MOONLIGHT_STAY_PAYLOAD.theme,
sections: MOONLIGHT_STAY_PAYLOAD.theme.sections.map((s) =>
s.id === 'booking' ? {...s, enabled: false} : s,
),
},
};
expect(visibleText(html(disabled))).not.toContain('예약 창구');
});
});
describe('구조화 데이터 — 예약 값이 화면과 어긋나지 않는가 (절대규칙 3)', () => {
it('makesOffer · potentialAction 을 포함한 전체 JSON-LD 가 화면 대조를 통과한다', () => {
const nodes = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
expect(verifyJsonLd(html(), nodes)).toEqual([]);
});
it('객실별 요금이 makesOffer 로 나가고, 가격은 화면과 같은 숫자다', () => {
const [business] = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
const offers = business.makesOffer as {name: string; price: number}[];
expect(offers.map((offer) => [offer.name, offer.price])).toEqual([
['Moonlight A동', 280000],
['Starlight B동', 300000],
]);
expect(visibleText(html())).toContain('280,000');
});
it('빈 방 재고를 모르므로 availability 를 주장하지 않는다', () => {
const [business] = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
for (const offer of business.makesOffer as Record<string, unknown>[]) {
expect(offer.availability).toBeUndefined();
}
});
it('예약 액션은 확정된 채널만 가리킨다', () => {
const [business] = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
const action = business.potentialAction as {target: {urlTemplate: string}};
expect(action.target.urlTemplate).toBe('https://m.place.naver.com/accommodation/1234567890');
});
it('예약 채널이 하나도 확정되지 않으면 예약 액션을 내보내지 않는다', () => {
const noChannel = {
...MOONLIGHT_STAY_PAYLOAD,
links: MOONLIGHT_STAY_PAYLOAD.links.map((link) => ({...link, confirmed: false})),
};
const [business] = collectJsonLd(noChannel, PAGE);
expect(business.potentialAction).toBeUndefined();
// 요금은 그대로 나간다 — 예약 창구가 전화뿐인 업소도 요금은 사실이다.
expect((business.makesOffer as unknown[]).length).toBe(2);
});
it('숙박이 아니면 예약 오퍼·액션을 붙이지 않는다', () => {
const cafe = {
...MOONLIGHT_STAY_PAYLOAD,
place: {...MOONLIGHT_STAY_PAYLOAD.place, category: PlaceCategory.CAFE},
};
const [business] = collectJsonLd(cafe, PAGE);
expect(business.makesOffer).toBeUndefined();
expect(business.potentialAction).toBeUndefined();
});
});
describe('예약 버튼은 예약 화면으로 바로 간다', () => {
/** 네이버 예약 링크가 붙은 payload — 수집이 플레이스 응답에서 받아온 주소. */
const withBooking: SitePayload = {
...MOONLIGHT_STAY_PAYLOAD,
links: [
...MOONLIGHT_STAY_PAYLOAD.links,
{
channel: LinkChannel.NAVER_BOOKING,
url: 'https://m.booking.naver.com/booking/6/bizes/1067685',
title: undefined,
confirmed: true,
},
],
};
it('네이버 예약이 플레이스보다 먼저 나온다 — 플레이스는 한 번 더 눌러야 한다', () => {
const links = stayBookingView(withBooking)!.links;
expect(links[0].channel).toBe(LinkChannel.NAVER_BOOKING);
expect(links.map((l) => l.channel)).toContain(LinkChannel.NAVER_PLACE);
});
it('버튼 문구가 "네이버 예약에서 예약" 이 되지 않는다', () => {
const text = visibleText(html(withBooking));
expect(text).toContain('네이버 예약으로 바로 예약하기');
expect(text).not.toContain('네이버 예약에서 예약');
});
it('예약 액션과 오퍼 URL 이 예약 화면을 가리킨다 — 화면 버튼과 같은 곳이다', () => {
const [business] = collectJsonLd(withBooking, PAGE);
const action = business.potentialAction as {target: {urlTemplate: string}};
expect(action.target.urlTemplate).toBe('https://m.booking.naver.com/booking/6/bizes/1067685');
for (const offer of business.makesOffer as {url?: string}[]) {
expect(offer.url).toBe('https://m.booking.naver.com/booking/6/bizes/1067685');
}
});
it('검색 결과 주소는 예약 버튼이 되지 않는다 — 눌러도 검색 화면이다', () => {
const searchOnly: SitePayload = {
...MOONLIGHT_STAY_PAYLOAD,
links: [
{
channel: LinkChannel.NAVER_PLACE,
url: 'https://map.naver.com/p/search/%EC%8A%A4%ED%85%8C%EC%9D%B4?c=15.00,0,0,0,dh',
title: '네이버 플레이스',
confirmed: true,
},
],
};
expect(stayBookingView(searchOnly)!.links).toHaveLength(0);
// 창구가 전화뿐이라는 사실을 말해 준다 — 버튼만 조용히 사라지면 안 된다.
expect(visibleText(html(searchOnly))).toContain('온라인 예약 채널은 등록되지 않았습니다');
});
});
describe('예약 데모 — 정적 HTML 에 무엇이 굽히나', () => {
it('날짜가 HTML 에 박히지 않는다 — 발행 시각의 날짜는 한 달 뒤 거짓이 된다', () => {
const baked = html();
// 서버 렌더에서는 달력을 그리지 않는다(mounted 게이트). 날짜 칩이 없어야 한다.
expect(baked).not.toContain('aria-pressed');
expect(visibleText(baked)).toContain('날짜 선택은 브라우저에서 열립니다');
});
it('구조화 데이터는 데모의 영향을 받지 않는다 — 예약 가능 여부를 주장하지 않는다', () => {
const nodes = collectJsonLd(MOONLIGHT_STAY_PAYLOAD, PAGE);
expect(verifyJsonLd(html(), nodes)).toEqual([]);
const [business] = nodes;
for (const offer of business.makesOffer as Record<string, unknown>[]) {
expect(offer.availability).toBeUndefined();
}
});
it('llms.txt 에 데모가 예약 창구로 실리지 않는다', () => {
const txt = renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD);
expect(txt).not.toContain('날짜 선택');
expect(txt).toContain('이 홈페이지는 빈 방 재고와 결제를 처리하지 않습니다.');
});
it('객실이 없으면 데모를 그리지 않는다 — 고를 것이 없다', () => {
const noUnits = {...MOONLIGHT_STAY_PAYLOAD, units: []};
expect(visibleText(html(noUnits))).not.toContain('날짜 선택은 브라우저에서 열립니다');
});
});
describe('llms.txt — AI 가 예약 경로를 읽는 자리', () => {
it('재고와 결제를 우리가 갖지 않는다고 명시한다', () => {
expect(renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD)).toContain(
'이 홈페이지는 빈 방 재고와 결제를 처리하지 않습니다.',
);
});
it('예약 창구와 기준 요금이 위쪽 블록에 있다 — 아래에만 있으면 답에 안 실린다', () => {
const txt = renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD);
expect(txt).toContain('- 전화 예약: 0507-1345-8821');
expect(txt).toContain('- 기준 요금 — Moonlight A동: 주중 1박 280,000원');
expect(txt.indexOf('## 예약')).toBeLessThan(txt.indexOf('## 이용 정보'));
});
it('확정 전 채널은 예약 블록에 없다', () => {
const txt = renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD);
const booking = txt.slice(txt.indexOf('## 예약'), txt.indexOf('## 이용 정보'));
expect(booking).not.toContain(UNCONFIRMED_URL);
});
});

View File

@ -1,4 +1,5 @@
import { import {
LinkChannel,
PlaceCategory, PlaceCategory,
factBool, factBool,
factValue, factValue,
@ -8,6 +9,7 @@ import {
selectPublishableFaqs, selectPublishableFaqs,
type FactEntry, type FactEntry,
type SitePayload, type SitePayload,
type UnitInfo,
} from '@o2o/shared'; } from '@o2o/shared';
/** /**
@ -191,10 +193,129 @@ export function businessJsonLd(payload: SitePayload): Json {
amenityFeature: amenityFeatures(payload.facts), amenityFeature: amenityFeatures(payload.facts),
sameAs: sameAs(payload), sameAs: sameAs(payload),
[spec.type === 'MenuItem' ? 'hasMenu' : 'containsPlace']: unitNodes(payload), [spec.type === 'MenuItem' ? 'hasMenu' : 'containsPlace']: unitNodes(payload),
makesOffer: stayOffers(payload),
potentialAction: reserveAction(payload),
...categoryExtras(payload), ...categoryExtras(payload),
}); });
} }
/**
* 객실 기준 요금 — **화면과 JSON-LD 가 같은 숫자를 쓰게 하는 단일 출처.**
*
* ★ 왜 여기 있나
* 화면의 요금 표기(`derive.unitPriceText`)와 JSON-LD 의 `makesOffer.price` 가 각자
* 계산하면 둘이 갈라질 수 있고, 갈라지는 순간 절대규칙 3(화면 = 구조화 데이터) 위반이라
* 발행 게이트가 사이트를 막는다. 그래서 숫자를 고르는 함수는 하나뿐이고, 양쪽이 이걸 쓴다.
* ★ 주중 요금을 기준으로 삼는다 — 손님이 "얼마부터"로 읽는 값이고, 주말/성수기는 그보다 비싸다.
* `label` 을 같이 돌려주는 이유: 화면이 "주중 280,000원" 이라고 쓰면 구조화 데이터의
* `unitText` 도 같은 말이어야 한다. 어느 요금인지 안 밝힌 가격은 그 자체로 오해다.
*/
export function unitBaseRate(unit: UnitInfo): {price: number; label: string} | undefined {
for (const [key, label] of [
['weekday_price', '주중 1박'],
['price', '1박'],
['weekend_price', '주말 1박'],
['peak_price', '성수기 1박'],
] as const) {
const price = Number(factValue(unit.facts, key)?.replace(/[^0-9]/g, ''));
if (Number.isFinite(price) && price > 0) return {price, label};
}
return undefined;
}
/**
* makesOffer — 객실별 1박 요금. **숙박만** 낸다.
*
* ★ 왜 `containsPlace` 안이 아니라 여기인가
* `HotelRoom` 은 Accommodation 이라 `offers` 가 정식 속성이 아니다. 요금을 파는 주체는
* 사업장이므로 Organization 계열의 `makesOffer` 가 맞는 자리다. 대조기(verify.ts)도
* 이 속성을 이름·가격 쌍으로 따로 검사한다.
* ★ `availability` 는 넣지 않는다. 우리는 빈 방 재고를 모른다 — 모르는 것을 InStock 으로
* 주장하면 그게 거짓이고, 예약 채널이 마감인데 AI 가 "예약 가능" 이라고 답하게 된다.
* ★ `url` 은 확정된 예약 채널뿐이다. 없으면 넣지 않는다(자기 페이지로 돌려보내는 예약 URL 은
* 예약 경로가 아니다).
*/
function stayOffers(payload: SitePayload): Json[] {
if (payload.place.category !== PlaceCategory.LODGING) return [];
const spec = UNIT_SPEC[payload.place.category];
const reserveUrl = bookingChannelUrl(payload);
return sanitizeUnits(payload.units)
.map((unit) => {
const rate = unitBaseRate(unit);
if (!rate) return null;
const offer: Json = compact({
'@type': 'Offer',
name: unit.name,
price: rate.price,
priceCurrency: 'KRW',
priceSpecification: compact({
'@type': 'UnitPriceSpecification',
price: rate.price,
priceCurrency: 'KRW',
unitText: rate.label,
}),
itemOffered: {'@id': `${siteUrl(payload, spec.path, unit.slug)}#unit`},
url: reserveUrl,
});
return offer;
})
.filter((offer): offer is Json => offer !== null);
}
/**
* 예약을 실제로 받는 채널 URL 하나. 화면의 예약 버튼과 같은 목록에서 고른다
* (`derive.bookingLinks` — 야놀자·여기어때·네이버 플레이스, 확정된 것만).
*
* ★ 목록을 두 곳에 적지 않으려면 derive 를 부르는 쪽이 자연스럽지만, 의존 방향이
* derive → jsonld 라 반대로 부를 수 없다. 채널 코드 목록은 이 파일에 두고
* derive 가 이걸 쓴다.
*/
export const BOOKING_CHANNELS: readonly LinkChannel[] = [
// ★ 순서가 곧 우선순위다. 예약 화면으로 **바로 가는** 채널이 앞이다.
// 네이버 예약(m.booking.naver.com)은 눌렀을 때 예약 화면 그 자체가 뜨고,
// 네이버 플레이스는 잘해야 가게 홈이라 예약을 한 번 더 눌러야 한다.
// 실측(2026-09-08): 자동 발견이 물어온 플레이스 URL 이 검색 결과 주소였던 사장님은
// "예약" 을 눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다.
LinkChannel.NAVER_BOOKING,
LinkChannel.YANOLJA,
LinkChannel.GOODCHOICE,
LinkChannel.NAVER_PLACE,
];
/**
* 예약 화면으로 보낼 URL 하나. **가장 앞선 채널**을 고른다(BOOKING_CHANNELS 순서).
*
* 화면의 예약 버튼과 `makesOffer.url`·`potentialAction` 이 같은 함수를 쓰므로,
* 구조화 데이터가 가리키는 곳과 손님이 눌러서 가는 곳이 어긋날 수 없다.
*/
function bookingChannelUrl(payload: SitePayload): string | undefined {
for (const channel of BOOKING_CHANNELS) {
const hit = payload.links.find((link) => link.confirmed && link.channel === channel);
if (hit) return hit.url;
}
return undefined;
}
/**
* potentialAction — "이 업소를 예약하는 방법" 을 기계가 읽는 형태로.
*
* AI 검색이 "여기 예약 어떻게 해요?" 에 답할 때 근거로 쓰는 자리다. 확정된 예약 채널이
* 없으면 내보내지 않는다 — 예약을 받지 않는 곳에 예약 액션을 붙이면 그게 거짓이다.
* ★ `actionPlatform` 은 쓰지 않는다. 값이 schema.org URL 이라 화면 대조에서 "화면에 없는
* URL" 로 잡히고, 플랫폼 구분은 이 사이트에서 아무 의미도 없다.
*/
function reserveAction(payload: SitePayload): Json | undefined {
if (payload.place.category !== PlaceCategory.LODGING) return undefined;
const url = bookingChannelUrl(payload);
if (!url) return undefined;
return {
'@type': 'ReserveAction',
target: {'@type': 'EntryPoint', urlTemplate: url, inLanguage: 'ko-KR'},
result: {'@type': 'LodgingReservation'},
};
}
/** 최저~최고 요금. 단위 fact 의 숫자만 모은다 — 확인 안 된 요금은 애초에 안 들어온다. */ /** 최저~최고 요금. 단위 fact 의 숫자만 모은다 — 확인 안 된 요금은 애초에 안 들어온다. */
function priceRange(payload: SitePayload): string | undefined { function priceRange(payload: SitePayload): string | undefined {
const prices = sanitizeUnits(payload.units) const prices = sanitizeUnits(payload.units)

View File

@ -1,4 +1,5 @@
import { import {
PlaceCategory,
factText, factText,
joinUrl, joinUrl,
sanitizeUnits, sanitizeUnits,
@ -6,7 +7,7 @@ import {
selectPublishableFaqs, selectPublishableFaqs,
type SitePayload, type SitePayload,
} from '@o2o/shared'; } from '@o2o/shared';
import {SCHEMA_TYPE, UNIT_SPEC} from './jsonld'; import {BOOKING_CHANNELS, SCHEMA_TYPE, UNIT_SPEC, unitBaseRate} from './jsonld';
/** /**
* llms.txt — LLM 이 이 가게를 설명할 때 쓸 사실 목록. * llms.txt — LLM 이 이 가게를 설명할 때 쓸 사실 목록.
@ -54,6 +55,9 @@ export function renderLlmsTxt(payload: SitePayload): string {
} }
lines.push(''); lines.push('');
// ── 예약 (숙박) ──────────────────────────────────────
pushStayBooking(lines, payload);
// ── 확인된 이용 정보 ────────────────────────────────── // ── 확인된 이용 정보 ──────────────────────────────────
if (facts.length > 0) { if (facts.length > 0) {
lines.push('## 이용 정보'); lines.push('## 이용 정보');
@ -135,6 +139,43 @@ export function renderLlmsTxt(payload: SitePayload): string {
return lines.join('\n'); return lines.join('\n');
} }
/**
* 예약 — 숙박에서 가장 많이 묻는 질의("어떻게 예약해요 / 얼마예요")의 답을 한 블록에 모은다.
*
* ★ 이용 정보·객실 절에 흩어져 있는 값을 한 번 더 쓰는 것이지만, LLM 은 이 파일을 위에서부터
* 읽고 답을 만든다. 예약 경로가 "공식 채널" 절 맨 아래에만 있으면 답에 안 실린다.
* ★ **재고와 결제를 우리가 갖지 않는다는 사실을 명시한다.** 이 문장이 없으면 LLM 이
* "공식 홈페이지에서 바로 예약할 수 있다"고 답한다 — 그건 거짓이고, 손님은 헛걸음한다.
* ★ 예약 채널은 확정된 것만이다. 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다.
*/
function pushStayBooking(lines: string[], payload: SitePayload) {
if (payload.place.category !== PlaceCategory.LODGING) return;
const links = payload.links.filter(
(link) => link.confirmed && BOOKING_CHANNELS.includes(link.channel),
);
const rates = sanitizeUnits(payload.units)
.map((unit) => {
const rate = unitBaseRate(unit);
return rate ? `${unit.name}: ${rate.label} ${rate.price.toLocaleString('ko-KR')}원` : null;
})
.filter((line): line is string => line !== null);
if (!payload.place.phone && links.length === 0 && rates.length === 0) return;
lines.push('## 예약');
lines.push('');
lines.push(
'이 홈페이지는 빈 방 재고와 결제를 처리하지 않습니다. ' +
'예약 가능 여부와 결제는 아래 창구에서 확인해야 합니다.',
);
lines.push('');
if (payload.place.phone) lines.push(`- 전화 예약: ${payload.place.phone}`);
for (const link of links) lines.push(`- ${link.title ?? '예약 채널'}: ${link.url}`);
for (const rate of rates) lines.push(`- 기준 요금 — ${rate}`);
lines.push('');
}
function pushFact(lines: string[], label: string, value: string | null | undefined) { function pushFact(lines: string[], label: string, value: string | null | undefined) {
lines.push(`- ${label}: ${value?.trim() ? value : '정보 없음'}`); lines.push(`- ${label}: ${value?.trim() ? value : '정보 없음'}`);
} }

View File

@ -52,6 +52,23 @@ describe('visibleText', () => {
}); });
}); });
describe('verifyJsonLd — 표기 차이로 사실을 막지 않는다', () => {
it('속성의 & 이스케이프를 표기 차이로 흡수한다 — 화면에 있는 이미지였다', () => {
// 실측(2026-09-07): 쿼리스트링 있는 이미지 URL 을 쓰는 사이트가 전부 발행 불가였다.
// HTML 속성에서는 & 가 &amp; 로 나가는데 JSON-LD 는 원본 & 를 갖고 있다.
const url = 'https://cdn.example.com/a.jpg?auto=format&fit=crop';
const body = `<img src="https://cdn.example.com/a.jpg?auto=format&amp;fit=crop" alt="객실">`;
expect(verifyJsonLd(page(body), [{'@type': 'LodgingBusiness', image: [url]}])).toEqual([]);
});
it('그래도 없는 이미지는 잡는다 — 이스케이프를 되돌려도 못 찾으면 실패다', () => {
const problems = verifyJsonLd(page(BODY), [
{'@type': 'LodgingBusiness', image: ['https://cdn.example.com/none.jpg?a=1&b=2']},
]);
expect(problems).toHaveLength(1);
});
});
describe('verifyJsonLd — 거짓을 잡는다', () => { describe('verifyJsonLd — 거짓을 잡는다', () => {
it('화면에 없는 전화번호를 주장하면 잡는다', () => { it('화면에 없는 전화번호를 주장하면 잡는다', () => {
const problems = verifyJsonLd(page(BODY), [ const problems = verifyJsonLd(page(BODY), [

View File

@ -24,6 +24,7 @@ const STRUCTURAL = new Set([
'priceCurrency', 'priceCurrency',
// ★ ㎡의 ISO 코드('MTK'). priceCurrency 와 같은 성격이라 화면에 나올 값이 아니다 — // ★ ㎡의 ISO 코드('MTK'). priceCurrency 와 같은 성격이라 화면에 나올 값이 아니다 —
// 빠져 있어서 객실 면적(room_size)이 있는 사업장은 전부 발행이 막혔다(실측: 가은채 객실 12개). // 빠져 있어서 객실 면적(room_size)이 있는 사업장은 전부 발행이 막혔다(실측: 가은채 객실 12개).
// ★ 사람이 읽는 단위 표기(`unitText`)는 여기 넣지 않는다 — 그건 화면에 있어야 하는 말이다.
'unitCode', 'unitCode',
// ★ units.length 로 만든 파생 카운트다. 화면에는 객실 카드가 그 개수만큼 있을 뿐 // ★ units.length 로 만든 파생 카운트다. 화면에는 객실 카드가 그 개수만큼 있을 뿐
// '12' 라는 숫자가 글자로 있지는 않다. 객실이 2~3개인 사업장만 우연히 통과했다. // '12' 라는 숫자가 글자로 있지는 않다. 객실이 2~3개인 사업장만 우연히 통과했다.
@ -140,6 +141,18 @@ function asShown(value: Scalar): string[] {
*/ */
export function verifyJsonLd(html: string, nodes: Json[]): string[] { export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const text = visibleText(html); const text = visibleText(html);
/**
* URL 대조용 사본 — 엔티티를 되돌린 HTML.
*
* ★ 왜 필요한가 (실측 2026-09-07, 데모 payload)
* `<img src="…?auto=format&fit=crop">` 는 HTML 로 나갈 때 `&` 가 `&amp;` 로 이스케이프된다.
* JSON-LD 의 `image` 는 원본 `&` 를 갖고 있으므로 원본 HTML 문자열에서는 절대 안 찾아진다 —
* **화면에 실제로 있는 이미지가 "화면에 없다"로 잡혀** 발행이 막혔다. 쿼리스트링 있는
* 이미지 URL 을 쓰는 사이트는 전부 이 오탐에 걸린다.
* 숫자 표기 차이를 `asShown()` 으로 흡수하는 것과 같은 이유다 — **표기 차이는 거짓이 아니다.**
* ★ 반대로 느슨해지지는 않는다: 되돌린 사본에서도 못 찾으면 그대로 실패다.
*/
const unescaped = unescapeHtml(html);
const problems: string[] = []; const problems: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@ -162,7 +175,9 @@ export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const token = String(value); const token = String(value);
// URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다. // URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다.
if (token.startsWith('http://') || token.startsWith('https://')) { if (token.startsWith('http://') || token.startsWith('https://')) {
if (!html.includes(token)) report(`${prop}: '${token}' 이 화면에 없다`); if (!html.includes(token) && !unescaped.includes(token)) {
report(`${prop}: '${token}' 이 화면에 없다`);
}
continue; continue;
} }
if (asShown(value).some((shown) => text.includes(shown))) continue; if (asShown(value).some((shown) => text.includes(shown))) continue;