[feat] solution/site,frontend,backend: 숙박 예약 구성 — 요금·인원·창구를 한자리에
숙박으로 발행하면 서버 기본표가 booking 섹션을 켜는데, 발행본이 읽는 fact (reservation_required·reservation_channel)가 **숙박 스키마에 없다**. 그래서 펜션·민박의 "실시간 예약" 섹션에는 전화번호 한 줄만 남았다 — 요금도 인원도 취소 규정도 없었다. 숙박은 예약이 곧 매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분인데, 그 답의 근거가 페이지에 없으면 AI 는 OTA 후기에서 추측한다. ★ 예약을 처리하게 만든 게 아니다. 재고도 결제도 갖지 않는다(PRODUCT.md 6절) — 날짜 선택기·예약 폼을 그리지 않았고, "여기서 결제되지 않는다" 를 화면 맨 앞과 llms.txt 에 명시했다. 없는 기능을 흉내내면 손님은 예약한 줄 알고 안 온다. - site/sections/StayBookingSection: 객실별 요금·인원 / 예약 창구(전화 + 확정 채널) / 예약 전 확인 8항목. 근거가 없으면 섹션째 안 나간다 - site/lib/derive: stayBookingView() 가 그릴지 말지까지 판단한다 — 내비·탭이 같은 함수를 본다(각자 판단하면 눌러도 아무 일 없는 탭이 생긴다). 예약 채널은 문의 목록에서 뺀다 - site/seo/jsonld: unitBaseRate() 를 요금 숫자의 단일 출처로. makesOffer(객실별 1박) · potentialAction(확정 채널만) 추가. availability 는 안 넣는다 — 빈 방을 모른다 - site/seo/llms: 숙박 ## 예약 블록을 위쪽에. 아래에만 있으면 답에 안 실린다 - frontend/industryData, backend/site_payload: 기본 섹션명 "실시간 예약" → "예약 안내". 실시간 예약을 하지 않는데 제목이 그렇게 말했다(두 파일은 parity 테스트가 묶는다) - site/seo/verify: 데모 payload 가 원래 굽히지 않던 오탐 둘을 고쳤다(main 에서 재현) — 속성의 & 이스케이프 때문에 화면에 있는 이미지 URL 을 못 찾던 것, ㎡ 의 단위 코드 MTK 를 본문에서 찾던 것. 되돌린 사본에서도 못 찾으면 그대로 실패다 tsc·eslint 통과, vitest 43 passed(신규 21). 데모 재굽기 성공 → /s/moonlight-stay-jeju 200. 백엔드 pytest 는 venv 가 없어 미실행 — 섹션표 parity 는 같은 방식으로 손대조했다.
This commit is contained in:
parent
6df125d840
commit
d498d36ccf
@ -5,6 +5,57 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
|
||||||
|
|
||||||
**무슨 일**
|
**무슨 일**
|
||||||
|
|||||||
@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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: '현재 기온과 사업장 주변 날씨' },
|
||||||
|
|||||||
@ -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')}원부터`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */
|
/** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */
|
||||||
@ -283,17 +286,14 @@ export function channelLabel(link: ChannelLink): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예약을 실제로 받는 채널.
|
* 예약을 실제로 받는 채널 목록은 `seo/jsonld.ts` 의 `BOOKING_CHANNELS` 한 벌이다.
|
||||||
*
|
*
|
||||||
* ★ 블로그·인스타그램은 뺀다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기" 자리에
|
* ★ 블로그·인스타그램은 그 목록에 없다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기"
|
||||||
* 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 뺀다 — 지금 보고 있는 이 사이트가
|
* 자리에 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 없다 — 지금 보고 있는 이
|
||||||
* 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다.
|
* 사이트가 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다.
|
||||||
|
* ★ 화면의 예약 버튼과 JSON-LD 의 `makesOffer.url`·`potentialAction` 이 **같은 링크**를
|
||||||
|
* 가리켜야 한다. 목록을 두 곳에 적으면 그게 조용히 갈라진다.
|
||||||
*/
|
*/
|
||||||
const BOOKING_CHANNELS: readonly number[] = [
|
|
||||||
LinkChannel.YANOLJA,
|
|
||||||
LinkChannel.GOODCHOICE,
|
|
||||||
LinkChannel.NAVER_PLACE,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만.
|
* 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만.
|
||||||
@ -312,6 +312,145 @@ export function bookingLinks(payload: SitePayload): ChannelLink[] {
|
|||||||
return confirmedLinks(payload, BOOKING_CHANNELS);
|
return confirmedLinks(payload, BOOKING_CHANNELS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
* 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
*
|
||||||
|
* ★ 왜 숙박만 따로 만드나
|
||||||
|
* `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[] {
|
||||||
return confirmedLinks(payload, CONTACT_CHANNELS);
|
return confirmedLinks(payload, CONTACT_CHANNELS);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import {PlaceCategory} from '@o2o/shared';
|
||||||
import {
|
import {
|
||||||
AboutSection,
|
AboutSection,
|
||||||
AnswerBlock,
|
AnswerBlock,
|
||||||
@ -14,10 +15,11 @@ import {
|
|||||||
LocationSection,
|
LocationSection,
|
||||||
RulesSection,
|
RulesSection,
|
||||||
SpaceSection,
|
SpaceSection,
|
||||||
|
StayBookingSection,
|
||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 홈.
|
* 홈.
|
||||||
@ -36,6 +38,7 @@ import {isSectionEnabled} from '@/lib/derive';
|
|||||||
*/
|
*/
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const payload = useSite();
|
const payload = useSite();
|
||||||
|
const isLodging = payload.place.category === PlaceCategory.LODGING;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 섹션 id → 발행본 컴포넌트.
|
* 섹션 id → 발행본 컴포넌트.
|
||||||
@ -53,7 +56,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,
|
||||||
@ -88,6 +93,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 {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},
|
||||||
|
|||||||
205
solution/site/src/sections/StayBookingSection.tsx
Normal file
205
solution/site/src/sections/StayBookingSection.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react';
|
||||||
|
import {useSite} from '@/lib/site-context';
|
||||||
|
import {channelLabel, sectionName, stayBookingView} from '@/lib/derive';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리.
|
||||||
|
*
|
||||||
|
* ★ 왜 `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>{`${channelLabel(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>
|
||||||
|
|
||||||
|
{/* ── 예약 전 확인 ────────────────────────────────────── */}
|
||||||
|
{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,8 @@ 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 {SpaceSection} from './SpaceSection';
|
export {SpaceSection} from './SpaceSection';
|
||||||
export {InquirySection} from './InquirySection';
|
export {InquirySection} from './InquirySection';
|
||||||
export {ExhibitionSection} from './ExhibitionSection';
|
export {ExhibitionSection} from './ExhibitionSection';
|
||||||
|
|||||||
220
solution/site/src/sections/stay-booking.test.tsx
Normal file
220
solution/site/src/sections/stay-booking.test.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* 숙박 예약 구성 — 이 검사가 지키는 것.
|
||||||
|
*
|
||||||
|
* 1. 미검증 fact·확정 전 링크가 예약 화면에 새지 않는다(절대규칙 1).
|
||||||
|
* 예약은 틀리면 바로 클레임이 나는 자리다 — 확인 안 된 요금이나 남의 숙소 예약 링크가
|
||||||
|
* 한 번 나가면 손님은 헛걸음하고, 그 전화는 사장님이 받는다.
|
||||||
|
* 2. 화면의 요금과 JSON-LD 의 `makesOffer.price` 가 **같은 숫자**다(절대규칙 3).
|
||||||
|
* 갈라지면 발행 게이트가 사이트를 막는다 — 조용히 틀리는 게 아니라 발행이 멈추는 종류다.
|
||||||
|
* 3. 예약 액션이 **실제로 예약이 되는 URL** 만 가리킨다.
|
||||||
|
* 확정 전 채널을 `potentialAction` 에 넣으면 AI 가 그 주소로 손님을 보낸다.
|
||||||
|
*/
|
||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
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('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,114 @@ 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[] = [
|
||||||
|
LinkChannel.YANOLJA,
|
||||||
|
LinkChannel.GOODCHOICE,
|
||||||
|
LinkChannel.NAVER_PLACE,
|
||||||
|
];
|
||||||
|
|
||||||
|
function bookingChannelUrl(payload: SitePayload): string | undefined {
|
||||||
|
return payload.links.find((link) => link.confirmed && BOOKING_CHANNELS.includes(link.channel))
|
||||||
|
?.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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), [
|
||||||
|
|||||||
@ -22,6 +22,10 @@ const STRUCTURAL = new Set([
|
|||||||
'@type',
|
'@type',
|
||||||
'@id',
|
'@id',
|
||||||
'priceCurrency',
|
'priceCurrency',
|
||||||
|
// 단위 코드(UN/CEFACT). 면적 76㎡ 를 화면은 '㎡' 로 쓰고 구조화 데이터는 'MTK' 로 쓴다 —
|
||||||
|
// 한국어 페이지에 'MTK' 가 찍힐 일은 없다. priceCurrency('KRW')와 같은 종류의 메타값이다.
|
||||||
|
// ★ 사람이 읽는 단위 표기(`unitText`)는 여기 넣지 않는다 — 그건 화면에 있어야 하는 말이다.
|
||||||
|
'unitCode',
|
||||||
'addressCountry',
|
'addressCountry',
|
||||||
'inLanguage',
|
'inLanguage',
|
||||||
// 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).
|
// 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).
|
||||||
@ -134,6 +138,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>();
|
||||||
@ -156,7 +172,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