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:
commit
7bbeb068a8
37
.env.example
37
.env.example
@ -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=
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
|
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
|
||||||
|
|||||||
183
docs/DEVLOG.md
183
docs/DEVLOG.md
@ -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 문자열**에서 찾았다. 속성으로 나갈 때 `&` 가 `&` 로
|
||||||
|
이스케이프되므로 쿼리스트링 있는 이미지 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) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
|
||||||
|
|
||||||
|
|||||||
@ -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(직접)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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 를 뽑는다.
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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: '현재 기온과 사업장 주변 날씨' },
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
{days.map((day, index) => (
|
||||||
|
<li
|
||||||
|
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)'}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Calendar className="size-3.5" />
|
<span className="text-[10px] opacity-70">{day.weekday}</span>
|
||||||
<span>네이버 예약 캘린더 열기</span>
|
<span className="font-bold">{day.day}</span>
|
||||||
</CtaLink>
|
</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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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: '여기어때'};
|
||||||
|
|||||||
@ -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]: '블로그',
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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},
|
||||||
|
|||||||
@ -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[] {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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},
|
||||||
|
|||||||
378
solution/site/src/sections/StayBookingDemo.tsx
Normal file
378
solution/site/src/sections/StayBookingDemo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
solution/site/src/sections/StayBookingSection.tsx
Normal file
209
solution/site/src/sections/StayBookingSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
304
solution/site/src/sections/stay-booking.test.tsx
Normal file
304
solution/site/src/sections/stay-booking.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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 : '정보 없음'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,23 @@ describe('visibleText', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('verifyJsonLd — 표기 차이로 사실을 막지 않는다', () => {
|
||||||
|
it('속성의 & 이스케이프를 표기 차이로 흡수한다 — 화면에 있는 이미지였다', () => {
|
||||||
|
// 실측(2026-09-07): 쿼리스트링 있는 이미지 URL 을 쓰는 사이트가 전부 발행 불가였다.
|
||||||
|
// HTML 속성에서는 & 가 & 로 나가는데 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&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), [
|
||||||
|
|||||||
@ -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 로 나갈 때 `&` 가 `&` 로 이스케이프된다.
|
||||||
|
* 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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user