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;