[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:
hbyang 2026-09-07 15:27:29 +09:00
parent 6df125d840
commit d498d36ccf
15 changed files with 846 additions and 24 deletions

View File

@ -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) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
**무슨 일**

View File

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

View File

@ -126,7 +126,7 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
{ id: 'rooms', type: 'rooms', name: '객실 안내', isLocked: false, isEnabled: true, description: '객실 타입, 구조, 비치 물품' },
{ id: 'info', type: 'info', name: '기본 정보', isLocked: true, 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: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '위치 안내 및 대중교통 경로' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 사업장 주변 날씨' },

View File

@ -534,6 +534,11 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
{id: 'intro', name: '소개', enabled: true, locked: false},
{id: 'rooms', name: '객실 안내', enabled: true, locked: false},
{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: 'weather', name: '날씨', enabled: true, locked: false},
{id: 'local', name: '지역 정보', enabled: true, locked: false},

View File

@ -12,7 +12,7 @@ import {
type SitePayload,
type UnitInfo,
} from '@o2o/shared';
import {UNIT_SPEC} from '@/seo/jsonld';
import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@/seo/jsonld';
/**
* payload .
@ -96,12 +96,15 @@ export function unitViews(payload: SitePayload): UnitView[] {
});
}
/**
* "얼마부터".
*
* `unitBaseRate`(seo/jsonld.ts) .
* JSON-LD , 3 .
*/
function unitPriceText(unit: UnitInfo): string | undefined {
const weekday = Number(factText(unit.facts, 'weekday_price')?.replace(/[^0-9]/g, ''));
const price = Number(factText(unit.facts, 'price')?.replace(/[^0-9]/g, ''));
const base = Number.isFinite(weekday) && weekday > 0 ? weekday : price;
if (!Number.isFinite(base) || base <= 0) return undefined;
return `${base.toLocaleString('ko-KR')}원부터`;
const rate = unitBaseRate(unit);
return rate ? `${rate.price.toLocaleString('ko-KR')}원부터` : undefined;
}
/** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */
@ -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 .
@ -312,6 +312,145 @@ export function bookingLinks(payload: SitePayload): ChannelLink[] {
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[] {
return confirmedLinks(payload, CONTACT_CHANNELS);
}

View File

@ -1,3 +1,4 @@
import {PlaceCategory} from '@o2o/shared';
import {
AboutSection,
AnswerBlock,
@ -14,10 +15,11 @@ import {
LocationSection,
RulesSection,
SpaceSection,
StayBookingSection,
UnitsSection,
} from '@/sections';
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() {
const payload = useSite();
const isLodging = payload.place.category === PlaceCategory.LODGING;
/**
* id .
@ -53,7 +56,9 @@ export function HomePage() {
menu: UnitsSection,
programs: UnitsSection,
rules: RulesSection,
booking: BookingSection,
// ★ 숙박은 예약 섹션이 다른 컴포넌트다. 같은 이름의 섹션이 업종에 따라 다른 것을
// 그리는 자리는 여기가 유일하다 — 이유는 StayBookingSection 머리주석.
booking: isLodging ? StayBookingSection : BookingSection,
space: SpaceSection,
inquiry: InquirySection,
exhibition: ExhibitionSection,
@ -88,6 +93,13 @@ export function HomePage() {
{/* 섹션 설정에 없더라도 오시는 길은 항상 나간다 — 위치 질의의 근거다. */}
{!isSectionEnabled(payload, 'map') && <LocationSection />}
{/* ** ** .
"사장님이 껐다" "payload 에 항목이 아예 없다" (`hasSection`)
payload· fixture booking ,
("어떻게 예약해요") .
. */}
{isLodging && !hasSection(payload, 'booking') && <StayBookingSection />}
</main>
);
}

View File

@ -1,6 +1,6 @@
import {Home, Image as ImageIcon, MapPin, Sparkles} from 'lucide-react';
import {CalendarCheck, Home, Image as ImageIcon, MapPin, Sparkles} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {isSectionEnabled, unitSpec} from '@/lib/derive';
import {isSectionEnabled, stayBookingView, unitSpec} from '@/lib/derive';
import {useActiveSection} from '@/lib/use-active-section';
/**
@ -20,6 +20,9 @@ export function MobileTabBar() {
const tabs = [
{id: 'top', label: '홈', icon: Home},
...(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}] : []),
{id: 'location', label: '위치', icon: MapPin},
];

View File

@ -1,6 +1,6 @@
import {Phone} from 'lucide-react';
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 = [
{label: '소개', href: '#about', show: isSectionEnabled(payload, 'intro')},
{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: '#location', show: true},
{label: 'FAQ', href: '#faq', show: payload.faqs.length > 0},

View 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>
);
}

View File

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

View 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);
});
});

View File

@ -1,4 +1,5 @@
import {
LinkChannel,
PlaceCategory,
factBool,
factValue,
@ -8,6 +9,7 @@ import {
selectPublishableFaqs,
type FactEntry,
type SitePayload,
type UnitInfo,
} from '@o2o/shared';
/**
@ -191,10 +193,114 @@ export function businessJsonLd(payload: SitePayload): Json {
amenityFeature: amenityFeatures(payload.facts),
sameAs: sameAs(payload),
[spec.type === 'MenuItem' ? 'hasMenu' : 'containsPlace']: unitNodes(payload),
makesOffer: stayOffers(payload),
potentialAction: reserveAction(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 의 숫자만 모은다 — 확인 안 된 요금은 애초에 안 들어온다. */
function priceRange(payload: SitePayload): string | undefined {
const prices = sanitizeUnits(payload.units)

View File

@ -1,4 +1,5 @@
import {
PlaceCategory,
factText,
joinUrl,
sanitizeUnits,
@ -6,7 +7,7 @@ import {
selectPublishableFaqs,
type SitePayload,
} from '@o2o/shared';
import {SCHEMA_TYPE, UNIT_SPEC} from './jsonld';
import {BOOKING_CHANNELS, SCHEMA_TYPE, UNIT_SPEC, unitBaseRate} from './jsonld';
/**
* llms.txt LLM .
@ -54,6 +55,9 @@ export function renderLlmsTxt(payload: SitePayload): string {
}
lines.push('');
// ── 예약 (숙박) ──────────────────────────────────────
pushStayBooking(lines, payload);
// ── 확인된 이용 정보 ──────────────────────────────────
if (facts.length > 0) {
lines.push('## 이용 정보');
@ -135,6 +139,43 @@ export function renderLlmsTxt(payload: SitePayload): string {
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) {
lines.push(`- ${label}: ${value?.trim() ? value : '정보 없음'}`);
}

View File

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

View File

@ -22,6 +22,10 @@ const STRUCTURAL = new Set([
'@type',
'@id',
'priceCurrency',
// 단위 코드(UN/CEFACT). 면적 76㎡ 를 화면은 '㎡' 로 쓰고 구조화 데이터는 'MTK' 로 쓴다 —
// 한국어 페이지에 'MTK' 가 찍힐 일은 없다. priceCurrency('KRW')와 같은 종류의 메타값이다.
// ★ 사람이 읽는 단위 표기(`unitText`)는 여기 넣지 않는다 — 그건 화면에 있어야 하는 말이다.
'unitCode',
'addressCountry',
'inLanguage',
// 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).
@ -134,6 +138,18 @@ function asShown(value: Scalar): string[] {
*/
export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const text = visibleText(html);
/**
* URL HTML.
*
* ( 2026-09-07, payload)
* `<img src="…?auto=format&fit=crop">` HTML `&` `&amp;` .
* JSON-LD `image` `&` HTML
* ** "화면에 없다" ** .
* URL .
* `asShown()` ** .**
* 않는다: 되돌린 .
*/
const unescaped = unescapeHtml(html);
const problems: string[] = [];
const seen = new Set<string>();
@ -156,7 +172,9 @@ export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const token = String(value);
// URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다.
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;
}
if (asShown(value).some((shown) => text.includes(shown))) continue;