From d498d36ccff1039045f3cfc11fb928aeb16007a3 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 7 Sep 2026 15:27:29 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[feat]=20solution/site,frontend,backend:=20?= =?UTF-8?q?=EC=88=99=EB=B0=95=20=EC=98=88=EC=95=BD=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=E2=80=94=20=EC=9A=94=EA=B8=88=C2=B7=EC=9D=B8=EC=9B=90=C2=B7?= =?UTF-8?q?=EC=B0=BD=EA=B5=AC=EB=A5=BC=20=ED=95=9C=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EC=97=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 숙박으로 발행하면 서버 기본표가 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 는 같은 방식으로 손대조했다. --- docs/DEVLOG.md | 51 ++++ solution/backend/services/site_payload.py | 2 +- solution/frontend/src/data/industryData.ts | 2 +- solution/site/src/fixtures/moonlight-stay.ts | 5 + solution/site/src/lib/derive.ts | 169 ++++++++++++-- solution/site/src/pages/HomePage.tsx | 16 +- solution/site/src/sections/MobileTabBar.tsx | 7 +- solution/site/src/sections/SiteHeader.tsx | 5 +- .../site/src/sections/StayBookingSection.tsx | 205 ++++++++++++++++ solution/site/src/sections/index.ts | 2 + .../site/src/sections/stay-booking.test.tsx | 220 ++++++++++++++++++ solution/site/src/seo/jsonld.ts | 106 +++++++++ solution/site/src/seo/llms.ts | 43 +++- solution/site/src/seo/verify.test.ts | 17 ++ solution/site/src/seo/verify.ts | 20 +- 15 files changed, 846 insertions(+), 24 deletions(-) create mode 100644 solution/site/src/sections/StayBookingSection.tsx create mode 100644 solution/site/src/sections/stay-booking.test.tsx diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index bdfbe3b..bfb7dba 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 **무슨 일** diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index 1281900..cd5790b 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -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), ], diff --git a/solution/frontend/src/data/industryData.ts b/solution/frontend/src/data/industryData.ts index 9f5fddd..f686845 100644 --- a/solution/frontend/src/data/industryData.ts +++ b/solution/frontend/src/data/industryData.ts @@ -126,7 +126,7 @@ export const INDUSTRY_CONFIGS: Record = { { 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: '현재 기온과 사업장 주변 날씨' }, diff --git a/solution/site/src/fixtures/moonlight-stay.ts b/solution/site/src/fixtures/moonlight-stay.ts index ca32309..42225e2 100644 --- a/solution/site/src/fixtures/moonlight-stay.ts +++ b/solution/site/src/fixtures/moonlight-stay.ts @@ -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}, diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index dc0274b..07822e9 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -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); } diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index cbc3635..bc617ac 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -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') && } + + {/* ★ 숙박에서 예약 안내는 **항목이 없을 때만** 기본으로 낸다. + "사장님이 껐다" 와 "payload 에 항목이 아예 없다" 는 다른 상태다(`hasSection`) — + 옛 payload·손으로 만든 fixture 에는 booking 항목이 없는데, 숙박에서 예약 창구가 + 없는 페이지는 이 업종 질의의 대부분("어떻게 예약해요")에 답을 못 한다. + 끈 것을 되살리지는 않는다 — 그건 사장님 결정이다. */} + {isLodging && !hasSection(payload, 'booking') && } ); } diff --git a/solution/site/src/sections/MobileTabBar.tsx b/solution/site/src/sections/MobileTabBar.tsx index accfe1c..06e57ea 100644 --- a/solution/site/src/sections/MobileTabBar.tsx +++ b/solution/site/src/sections/MobileTabBar.tsx @@ -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}, ]; diff --git a/solution/site/src/sections/SiteHeader.tsx b/solution/site/src/sections/SiteHeader.tsx index 0f805f1..21684a5 100644 --- a/solution/site/src/sections/SiteHeader.tsx +++ b/solution/site/src/sections/SiteHeader.tsx @@ -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}, diff --git a/solution/site/src/sections/StayBookingSection.tsx b/solution/site/src/sections/StayBookingSection.tsx new file mode 100644 index 0000000..8f93591 --- /dev/null +++ b/solution/site/src/sections/StayBookingSection.tsx @@ -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 ( +
+
+

+ + Reservation +

+ +

+ {sectionName(payload, 'booking', '예약 안내')} +

+ {/* ★ "여기서 결제되지 않는다"를 먼저 말한다. 예약 버튼을 누른 뒤에 알게 되면 + 손님은 속은 것으로 느끼고, 그 인상은 업소가 가져간다. */} +

+ 빈 방 확인과 결제는 아래 예약 창구에서 진행됩니다. 이 페이지에서는 요금과 이용 조건만 + 안내합니다. +

+ +
+ {/* ── 객실별 요금 · 인원 ───────────────────────────────── */} + {offers.length > 0 && ( +
+

+ + 객실별 요금 · 인원 +

+ +
    + {offers.map((offer) => ( +
  • +
    +

    {offer.name}

    + {offer.baseRateText && ( + + {offer.baseRateText} + + )} +
    + + {offer.capacityText && ( +

    {offer.capacityText}

    + )} + + {offer.rateRows.length > 0 && ( +
    + {offer.rateRows.map((row) => ( +
    +
    {row.label}
    +
    {row.value}
    +
    + ))} +
    + )} + + {/* 사진·전체 스펙은 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */} + + {offer.name} 사진 · 상세 보기 + +
  • + ))} +
+
+ )} + + {/* ── 예약 창구 ──────────────────────────────────────── */} +
+

예약 창구

+ + {phone && ( + + + 전화 예약 {phone} + + )} + + {/* ★ 확정된 채널만 나간다 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다. + 눌렀는데 남의 숙소가 뜨면 그 예약은 영영 우리 것이 아니다. */} + {links.map((link) => ( + + {`${channelLabel(link)}에서 예약`} + + + ))} + + {contacts.length > 0 && ( +
+

문의

+ +
+ )} + + {links.length === 0 && ( + // 채널이 하나도 확정되지 않은 업소 — 전화가 유일한 창구다. 그 사실을 말해 준다. +

+ 온라인 예약 채널은 등록되지 않았습니다. 예약은 전화로 문의해 주세요. +

+ )} +
+
+ + {/* ── 예약 전 확인 ────────────────────────────────────── */} + {notices.length > 0 && ( +
+

+ + 예약 전 확인 +

+ + {/* 확인된 fact 만 줄이 된다 — "확인 중" 이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다. */} +
+ {notices.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/solution/site/src/sections/index.ts b/solution/site/src/sections/index.ts index a6ec6ed..ae041b9 100644 --- a/solution/site/src/sections/index.ts +++ b/solution/site/src/sections/index.ts @@ -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'; diff --git a/solution/site/src/sections/stay-booking.test.tsx b/solution/site/src/sections/stay-booking.test.tsx new file mode 100644 index 0000000..f729d6b --- /dev/null +++ b/solution/site/src/sections/stay-booking.test.tsx @@ -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 [ + '', + head, + '', + `
${render(payload)}
`, + ``, + '', + ].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[]) { + 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); + }); +}); diff --git a/solution/site/src/seo/jsonld.ts b/solution/site/src/seo/jsonld.ts index 7d0d463..0174843 100644 --- a/solution/site/src/seo/jsonld.ts +++ b/solution/site/src/seo/jsonld.ts @@ -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) diff --git a/solution/site/src/seo/llms.ts b/solution/site/src/seo/llms.ts index 800984f..a3aebb9 100644 --- a/solution/site/src/seo/llms.ts +++ b/solution/site/src/seo/llms.ts @@ -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 : '정보 없음'}`); } diff --git a/solution/site/src/seo/verify.test.ts b/solution/site/src/seo/verify.test.ts index da7a660..8152f7a 100644 --- a/solution/site/src/seo/verify.test.ts +++ b/solution/site/src/seo/verify.test.ts @@ -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 = `객실`; + 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), [ diff --git a/solution/site/src/seo/verify.ts b/solution/site/src/seo/verify.ts index 9a34007..977b6c8 100644 --- a/solution/site/src/seo/verify.ts +++ b/solution/site/src/seo/verify.ts @@ -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) + * `` 는 HTML 로 나갈 때 `&` 가 `&` 로 이스케이프된다. + * JSON-LD 의 `image` 는 원본 `&` 를 갖고 있으므로 원본 HTML 문자열에서는 절대 안 찾아진다 — + * **화면에 실제로 있는 이미지가 "화면에 없다"로 잡혀** 발행이 막혔다. 쿼리스트링 있는 + * 이미지 URL 을 쓰는 사이트는 전부 이 오탐에 걸린다. + * 숫자 표기 차이를 `asShown()` 으로 흡수하는 것과 같은 이유다 — **표기 차이는 거짓이 아니다.** + * ★ 반대로 느슨해지지는 않는다: 되돌린 사본에서도 못 찾으면 그대로 실패다. + */ + const unescaped = unescapeHtml(html); const problems: string[] = []; const seen = new Set(); @@ -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; From b665c34ac211d4701e986860993521d5ecf6ded4 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 7 Sep 2026 16:05:01 +0900 Subject: [PATCH 2/6] =?UTF-8?q?[fix]=20deploy:=20.env.example=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=93=B0=EB=A9=B4=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=EC=9D=B4=20=EC=95=88=20=EB=90=90=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20DB=5FHOST=20=EC=99=80=20=EC=A4=84=EB=81=9D=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문서대로 `cp .env.example .env` → `docker compose up -d` → 발행을 걸면 게이트는 통과하고 발행만 실패한다. 두 함정이 겹쳐 있었다(둘 다 실측). - DB_HOST=127.0.0.1: 컨테이너 안의 127.0.0.1 은 그 컨테이너다. 증상이 고약하다 — API 는 /healthz 가 DB 를 안 보므로 200 healthy 로 뜨고 워커만 조용히 재시작을 반복한다. compose 기본값(host.docker.internal)을 기본으로 올리고, 127.0.0.1 은 네이티브 실행용이라고 적었다 - 줄 끝 주석이 값이 된다: env_file 은 `KEY= # 설명` 을 빈 값으로 읽지 않는다. Azure 를 끈 로컬에서 is_configured() 가 참이 되어 발행 잡이 업로드를 시도하고 죽었다 (Connection string is either blank or malformed). 같은 모양 5개를 윗줄로 올리고, 파일 머리에 규칙을 근거와 함께 박았다 검증: web4ai_db 신규 생성 + init.sql → compose up → demo_build.py 발행 게이트 통과 · published:true · http://localhost/s/ 200. --- .env.example | 27 +++++++++++++++++++++------ docs/DEVLOG.md | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 950d8b1..e945f04 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,20 @@ # cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다. # 우선순위: 실제 환경변수(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 가 읽는다) 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_USER=postgres DB_PASSWORD= @@ -17,12 +27,15 @@ JWT_REFRESH_SECRET= # 키가 비면 그 어댑터만 꺼진다. 서버는 뜬다. PERPLEXITY_API_KEY= -COLLECT_USE_PERPLEXITY=0 # Perplexity 채널 발견. 0=끔(기본) +# Perplexity 채널 발견. 0=끔(기본) +COLLECT_USE_PERPLEXITY=0 NAVER_CLIENT_ID= NAVER_CLIENT_SECRET= -KAKAO_REST_API_KEY= # 미발급. 없으면 네이버 지역검색을 쓴다 +# 미발급. 없으면 네이버 지역검색을 쓴다 +KAKAO_REST_API_KEY= GEMINI_API_KEY= -TOUR_API_KEY= # 디코딩된 키(인코딩 키는 이중 인코딩된다) +# 디코딩된 키(인코딩 키는 이중 인코딩된다) +TOUR_API_KEY= # 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다). # Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션) @@ -46,8 +59,10 @@ GOOGLE_CLIENT_ID= # canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다. # 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다. # SITE_PUBLIC_HOST=web4ai.o2osolution.ai -INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상) -AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다 +# 비우면 색인 통보를 건너뛴다(발행은 정상) +INDEXNOW_KEY= +# 비우면 로컬 발행만 한다 +AZURE_STORAGE_CONNECTION_STRING= AZURE_STORAGE_CONTAINER= AZURE_STORAGE_PREFIX= diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index bfb7dba..717e63c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,32 @@ --- +## 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`. + +**고친 것** — `.env.example`: `DB_HOST` 기본값을 `host.docker.internal` 로 바꾸고 네이티브 +실행 때만 127.0.0.1 이라는 것을 주석으로 남겼다. 값 뒤 주석은 전부 **윗줄로 올렸고**, +파일 머리에 "값 뒤에 주석을 붙이지 않는다" 를 근거와 함께 박았다. + +**검증** — 새 DB(`web4ai_db`)에 `init.sql` 적용 → `docker compose up -d` → 워커 정상 기동 → +`scripts/demo_build.py` 로 발행: 게이트 통과 · `published: true` · 프리렌더가 굽고 +`http://localhost/s/` 200. ★ 참고로 `demo_build.py` 는 자기 안에서 워커를 한 번 돌리는데, +compose 워커가 잡을 먼저 집어가므로 **스크립트 출력은 "게이트 거부"로 보인다** — 실제 결과는 +`job.jobs.result` 와 워커 로그에 있다. + ## 2026-09-07 — 숙박 예약 구성 — "실시간 예약" 섹션이 전화번호 한 줄이었다 **왜** From 55ee968f9d76d3fdd92d749b6b257b3d39729ed2 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 7 Sep 2026 16:19:34 +0900 Subject: [PATCH 3/6] =?UTF-8?q?[fix]=20deploy:=20=EC=95=B1=EC=9D=B4=20?= =?UTF-8?q?=EB=B6=80=EB=A5=B4=EB=8A=94=20API=20=EC=A3=BC=EC=86=8C=EB=A5=BC?= =?UTF-8?q?=20=ED=95=9C=20=EC=98=A4=EB=A6=AC=EC=A7=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20:9800=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=B4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=84=20=EB=A7=89=EC=95=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nginx/site.conf 는 /v1 을 같은 오리진으로 프록시하고 주석에도 "앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다" 고 적혀 있는데, compose 빌드 인자 기본값이 VITE_API_BASE_URL=http://localhost:9800 이었다. :80 으로 앱을 열면 번들이 :9800 을 부르므로 스스로 크로스 오리진이 되고, CLIENT_URL 기본값(3000~3005)에 http://localhost 가 없어 로그인만 실패한다. 증상이 사람을 속인다 — 서버는 200 에 토큰까지 내려보내고 브라우저가 allow-origin 이 없어 그 응답을 버리므로, 화면에는 "로그인에 실패했습니다" 만 뜬다. 비밀번호를 의심하게 된다. - docker-compose.yml · .env.example: 기본값을 앱과 같은 오리진(http://localhost)으로. CORS 를 허용해 뚫는 게 아니라 크로스 오리진을 만들지 않는다 - .env.example: PUBLIC_API_BASE_URL 을 주석이 아니라 값으로 내놨다 — 주석으로 두면 compose 기본값이 이기고, 그 기본값이 문제였다 검증: down -v 후 up -d --build → 번들의 localhost:9800 참조 0건 · POST http://localhost/v1/auth/login 200(프리플라이트 없음) · 프리렌더 재굽기 2건 · / · /s/ · /s/ 전부 200. --- .env.example | 10 ++++++++-- docker-compose.yml | 4 +++- docs/DEVLOG.md | 22 ++++++++++++++++++---- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index e945f04..0caa193 100644 --- a/.env.example +++ b/.env.example @@ -52,8 +52,14 @@ GOOGLE_CLIENT_ID= # ── solution/frontend 브라우저가 부르는 주소 (compose 가 VITE_* 로 주입) # ★ 브라우저가 부르는 주소다. 서버에 올리면 localhost 는 즉시 틀린다. -# PUBLIC_API_BASE_URL=http://localhost:9800 -# PUBLIC_WEB_BASE_URL=http://localhost:3000 +# ★ **앱과 같은 오리진을 적는다.** nginx(:80)가 /v1 을 같은 오리진으로 프록시하므로 +# (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 도 같이 본다 # canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다. diff --git a/docker-compose.yml b/docker-compose.yml index 59f677a..f361e00 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -260,7 +260,9 @@ services: args: # ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다 # → 주소를 바꿨으면 `./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_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost} # ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 717e63c..6be445e 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -21,11 +21,25 @@ DB 를 안 보므로 **200 healthy** 로 뜨고, **워커만 조용히 재시작 같은 모양이 5개였다: `COLLECT_USE_PERPLEXITY`(값 `0` 이 `"0 # ..."` 가 된다) · `KAKAO_REST_API_KEY` · `TOUR_API_KEY` · `INDEXNOW_KEY` · `AZURE_STORAGE_CONNECTION_STRING`. -**고친 것** — `.env.example`: `DB_HOST` 기본값을 `host.docker.internal` 로 바꾸고 네이티브 -실행 때만 127.0.0.1 이라는 것을 주석으로 남겼다. 값 뒤 주석은 전부 **윗줄로 올렸고**, -파일 머리에 "값 뒤에 주석을 붙이지 않는다" 를 근거와 함께 박았다. +**3) 앱이 스스로 크로스 오리진을 만든다.** `nginx/site.conf` 는 `/v1` 을 같은 오리진으로 +프록시하고 주석에도 "앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다" 고 적혀 +있는데, compose 의 빌드 인자 기본값이 `VITE_API_BASE_URL=http://localhost:9800` 이었다. +`:80` 으로 앱을 열면 번들이 `:9800` 을 부르므로 크로스 오리진이 되고, `CLIENT_URL` 기본값 +(3000~3005)에 `http://localhost` 가 없어 **로그인만 계속 실패한다.** 증상이 사람을 속인다 — +서버는 200 에 토큰까지 내려보내고, 브라우저가 `allow-origin` 이 없어 그 응답을 버리므로 +화면에는 "로그인에 실패했습니다" 만 뜬다. 비밀번호를 의심하게 된다. -**검증** — 새 DB(`web4ai_db`)에 `init.sql` 적용 → `docker compose up -d` → 워커 정상 기동 → +**고친 것** +- `.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/` 전부 200. 그리고 → `scripts/demo_build.py` 로 발행: 게이트 통과 · `published: true` · 프리렌더가 굽고 `http://localhost/s/` 200. ★ 참고로 `demo_build.py` 는 자기 안에서 워커를 한 번 돌리는데, compose 워커가 잡을 먼저 집어가므로 **스크립트 출력은 "게이트 거부"로 보인다** — 실제 결과는 From 66f81f36316c8e50f024477e30d0190864f54b58 Mon Sep 17 00:00:00 2001 From: hbyang Date: Tue, 8 Sep 2026 10:52:36 +0900 Subject: [PATCH 4/6] =?UTF-8?q?[feat]=20solution/backend,site:=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EB=B2=84=ED=8A=BC=EC=9D=84=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=98=88=EC=95=BD=20=ED=99=94=EB=A9=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=B4=20=EB=9C=A8=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번 더 눌러야 하고, 자동 발견이 물어온 URL 이 map.naver.com/p/search/… 인 사장님은 예약하려고 눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다. 주소를 지어낼 필요가 없다 — 플레이스 응답의 __APOLLO_STATE__ 가 예약 주소를 직접 준다 (실측 2026-09-08, place 1273971279): naverBooking.naverBookingUrl = https://m.booking.naver.com/booking/6/bizes/1067685 ★ bookingBusinessId + businessTypeId 로 조립하지 않는다. 조립하면 예약을 안 받는 업소에도 주소가 생기고, 빈 화면을 본 손님은 그 가게가 예약을 안 받는 줄로 읽는다. - LinkChannel.NAVER_BOOKING=7 (백엔드·shared·init.sql 주석) - collector/base: RawSource.booking_url — 채널이 스스로 알려준 예약 주소 - naver_place_adapter._booking_url: ROOT_QUERY.placeDetail(...).naverBooking 에서 읽는다 - collect_service._store_booking_link: 예약 채널로 등록·자동 확정(근거는 discover_naver_place 와 같다 — 이미 확정된 플레이스가 내놓은 자기 예약 주소다) - site/seo/jsonld BOOKING_CHANNELS: 순서가 우선순위(예약→야놀자→여기어때→플레이스). 화면 버튼과 makesOffer.url·potentialAction 이 같은 함수를 쓴다 - site/lib/derive: 같은 순서로 정렬 + 검색 결과 주소 배제. 문구는 bookingCtaLabel 이 "네이버 예약으로 바로 예약하기"로 낸다("네이버 예약에서 예약"이 되지 않게) - 빌더도 채널을 안다(useCollectFlow 라벨 · ChannelUrlInput 호스트 판정) tsc·eslint 통과, vitest 47 passed(신규 4). 어댑터는 실제 네이버 응답으로 확인. --- docs/DEVLOG.md | 44 +++++++++++++++ postgres-init/init-data/init.sql | 2 +- solution/backend/common/enums.py | 5 ++ solution/backend/services/collect_service.py | 36 ++++++++++++ solution/backend/services/collector/base.py | 4 ++ .../services/collector/naver_place_adapter.py | 33 ++++++++++- .../features/onboarding/ChannelUrlInput.tsx | 3 + .../src/features/onboarding/useCollectFlow.ts | 1 + solution/shared/src/types/domain.ts | 2 + solution/site/src/lib/derive.ts | 32 ++++++++++- solution/site/src/sections/BookingSection.tsx | 4 +- .../site/src/sections/StayBookingSection.tsx | 4 +- .../site/src/sections/stay-booking.test.tsx | 55 +++++++++++++++++++ solution/site/src/seo/jsonld.ts | 19 ++++++- 14 files changed, 235 insertions(+), 9 deletions(-) diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 6be445e..66cce80 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,50 @@ --- +## 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` 한 다음 발행을 걸어 봤다. diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 98b42e5..d6e8e00 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -122,7 +122,7 @@ CREATE TABLE IF NOT EXISTS place.place_aliases ( CREATE TABLE IF NOT EXISTS place.place_links ( link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 링크 식별자(PK) 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 title VARCHAR(300) NULL, -- 제목/스니펫 discovered_by SMALLINT NOT NULL, -- 발견 주체(SourceType): 2=api(Perplexity) 1=owner(직접) diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index 0c96a2d..d0b1cb1 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -267,6 +267,11 @@ class LinkChannel(CodeEnum): INSTAGRAM = 4 OFFICIAL_SITE = 5 # 사장님 자체 홈페이지 BLOG = 6 + # ★ 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다. + # 플레이스 홈은 예약 버튼을 한 번 더 눌러야 하고, 자동 발견이 검색 URL 을 물어온 + # 경우에는 아예 검색 결과가 뜬다 — 발행본의 "예약" 버튼이 그리로 가면 손님은 + # 예약을 포기한다. 주소는 지어내지 않는다: 플레이스 응답의 naverBookingUrl 그대로다. + NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com) ETC = 99 diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 8234d26..d429516 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -518,6 +518,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_total = max(unit_total, len(unit_map)) f = await store_facts(actor, place_id, [source], unit_map) @@ -545,6 +547,40 @@ async def run_collect(job: dict) -> dict: 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, company_id: str, status: PlaceStatus): """수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다.""" await DB_SESSION_MNG.execute_lambda_claim( diff --git a/solution/backend/services/collector/base.py b/solution/backend/services/collector/base.py index 800bd47..825d844 100644 --- a/solution/backend/services/collector/base.py +++ b/solution/backend/services/collector/base.py @@ -99,6 +99,10 @@ class RawSource: text: Optional[str] = None # 태그 걷어낸 본문 facts: list[CollectedFact] = field(default_factory=list) media: list[CollectedMedia] = field(default_factory=list) + # ★ 수집 중 **그 채널이 스스로 알려준** 예약 주소. 우리가 만든 주소가 아니다. + # 네이버 플레이스 응답의 naverBookingUrl 이 여기 실린다 — 발행본의 "예약" 버튼이 + # 플레이스 홈(한 번 더 눌러야 한다)이나 검색 결과가 아니라 예약 화면으로 바로 가게 하는 값. + booking_url: Optional[str] = None def __post_init__(self): if not (self.url or "").strip(): diff --git a/solution/backend/services/collector/naver_place_adapter.py b/solution/backend/services/collector/naver_place_adapter.py index 3898d07..3bccef9 100644 --- a/solution/backend/services/collector/naver_place_adapter.py +++ b/solution/backend/services/collector/naver_place_adapter.py @@ -145,8 +145,12 @@ class NaverPlaceAdapter: facts = self._to_facts(base, 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( url=url, adapter_id=self.id, @@ -161,9 +165,36 @@ class NaverPlaceAdapter: ), facts=facts, 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: """URL 에서 place id 를 뽑는다. diff --git a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx index 53a38e1..6f19365 100644 --- a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx +++ b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx @@ -32,6 +32,9 @@ function guessChannel(url: string): {channel: LinkChannelCode; label: string} | if (u.includes('blog.naver') || u.includes('cafe.naver')) return null; 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('goodchoice.kr') || u.includes('yeogi.com')) { return {channel: LinkChannel.GOODCHOICE, label: '여기어때'}; diff --git a/solution/frontend/src/features/onboarding/useCollectFlow.ts b/solution/frontend/src/features/onboarding/useCollectFlow.ts index 08f7f99..7ca926b 100644 --- a/solution/frontend/src/features/onboarding/useCollectFlow.ts +++ b/solution/frontend/src/features/onboarding/useCollectFlow.ts @@ -26,6 +26,7 @@ export const CHANNEL_LABEL: Record = { [LinkChannel.YANOLJA]: '야놀자', [LinkChannel.GOODCHOICE]: '여기어때', [LinkChannel.NAVER_PLACE]: '네이버 플레이스', + [LinkChannel.NAVER_BOOKING]: '네이버 예약', [LinkChannel.INSTAGRAM]: '인스타그램', [LinkChannel.OFFICIAL_SITE]: '공식 홈페이지', [LinkChannel.BLOG]: '블로그', diff --git a/solution/shared/src/types/domain.ts b/solution/shared/src/types/domain.ts index 78b194e..a612cdb 100644 --- a/solution/shared/src/types/domain.ts +++ b/solution/shared/src/types/domain.ts @@ -77,6 +77,8 @@ export const LinkChannel = { INSTAGRAM: 4, OFFICIAL_SITE: 5, BLOG: 6, + /** 네이버 예약 화면 그 자체(m.booking.naver.com). 플레이스 홈과 가른다 — 발행본의 예약 버튼이 쓴다. */ + NAVER_BOOKING: 7, ETC: 99, } as const; export type LinkChannel = (typeof LinkChannel)[keyof typeof LinkChannel]; diff --git a/solution/site/src/lib/derive.ts b/solution/site/src/lib/derive.ts index 07822e9..e5d0fdc 100644 --- a/solution/site/src/lib/derive.ts +++ b/solution/site/src/lib/derive.ts @@ -272,6 +272,7 @@ export function exhibitionRows(payload: SitePayload): InfoRow[] { /** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */ export const CHANNEL_LABEL: Record = { + [LinkChannel.NAVER_BOOKING]: '네이버 예약', [LinkChannel.YANOLJA]: '야놀자', [LinkChannel.GOODCHOICE]: '여기어때', [LinkChannel.NAVER_PLACE]: '네이버 플레이스', @@ -285,6 +286,18 @@ export function channelLabel(link: ChannelLink): string { return link.title ?? CHANNEL_LABEL[link.channel] ?? '채널'; } +/** + * 예약 버튼에 찍을 말. + * + * ★ `${channelLabel}에서 예약` 로 일괄 처리하면 네이버 예약이 "네이버 예약에서 예약" 이 된다. + * 그리고 이 채널은 다른 채널과 성격이 다르다 — 누르면 **예약 화면 그 자체**가 뜬다. + * 그 차이를 버튼이 말해 줘야 손님이 한 번 더 눌러야 하는지 아닌지를 안다. + */ +export function bookingCtaLabel(link: ChannelLink): string { + if (link.channel === LinkChannel.NAVER_BOOKING) return '네이버 예약으로 바로 예약하기'; + return `${channelLabel(link)}에서 예약`; +} + /** * 예약을 실제로 받는 채널 목록은 `seo/jsonld.ts` 의 `BOOKING_CHANNELS` 한 벌이다. * @@ -308,8 +321,25 @@ function confirmedLinks(payload: SitePayload, channels: readonly number[]): Chan 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[] { - 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); } /** diff --git a/solution/site/src/sections/BookingSection.tsx b/solution/site/src/sections/BookingSection.tsx index d0e82b0..ca8e84e 100644 --- a/solution/site/src/sections/BookingSection.tsx +++ b/solution/site/src/sections/BookingSection.tsx @@ -1,6 +1,6 @@ import {ArrowUpRight, CalendarCheck, Phone} from 'lucide-react'; import {useSite} from '@/lib/site-context'; -import {bookingLinks, bookingRows, channelLabel, sectionName} from '@/lib/derive'; +import {bookingCtaLabel, bookingLinks, bookingRows, sectionName} from '@/lib/derive'; /** * 예약 안내. @@ -84,7 +84,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" style={{backgroundColor: 'var(--color-surface-alt)'}} > - {channelLabel(link)} + {bookingCtaLabel(link)} ))} diff --git a/solution/site/src/sections/StayBookingSection.tsx b/solution/site/src/sections/StayBookingSection.tsx index 8f93591..f498430 100644 --- a/solution/site/src/sections/StayBookingSection.tsx +++ b/solution/site/src/sections/StayBookingSection.tsx @@ -1,6 +1,6 @@ import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react'; import {useSite} from '@/lib/site-context'; -import {channelLabel, sectionName, stayBookingView} from '@/lib/derive'; +import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@/lib/derive'; /** * 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리. @@ -139,7 +139,7 @@ export function StayBookingSection() { 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)'}} > - {`${channelLabel(link)}에서 예약`} + {bookingCtaLabel(link)} ))} diff --git a/solution/site/src/sections/stay-booking.test.tsx b/solution/site/src/sections/stay-booking.test.tsx index f729d6b..b19fa32 100644 --- a/solution/site/src/sections/stay-booking.test.tsx +++ b/solution/site/src/sections/stay-booking.test.tsx @@ -12,6 +12,7 @@ import {describe, expect, it} from 'vitest'; import { + LinkChannel, PlaceCategory, FactStatus, sanitizePayloadForPublish, @@ -198,6 +199,60 @@ describe('구조화 데이터 — 예약 값이 화면과 어긋나지 않는가 }); }); +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('llms.txt — AI 가 예약 경로를 읽는 자리', () => { it('재고와 결제를 우리가 갖지 않는다고 명시한다', () => { expect(renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD)).toContain( diff --git a/solution/site/src/seo/jsonld.ts b/solution/site/src/seo/jsonld.ts index 0174843..e75f2cc 100644 --- a/solution/site/src/seo/jsonld.ts +++ b/solution/site/src/seo/jsonld.ts @@ -272,14 +272,29 @@ function stayOffers(payload: SitePayload): Json[] { * 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 { - return payload.links.find((link) => link.confirmed && BOOKING_CHANNELS.includes(link.channel)) - ?.url; + for (const channel of BOOKING_CHANNELS) { + const hit = payload.links.find((link) => link.confirmed && link.channel === channel); + if (hit) return hit.url; + } + return undefined; } /** From 0b33f035ef853fbe5d1998203ad6fccfee804e81 Mon Sep 17 00:00:00 2001 From: hbyang Date: Wed, 9 Sep 2026 10:19:07 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[feat]=20solution/site:=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EC=95=88=EB=82=B4=20=EC=95=88=EC=97=90=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=C2=B7=EC=8B=9C=EA=B0=84=20=EB=AA=A9=EC=97=85=20?= =?UTF-8?q?=E2=80=94=20=EC=97=B0=EB=8F=99=20=EC=97=86=EC=9D=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=EB=A7=8C=20=EB=8F=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 예약 흐름을 눈으로 보려고 StayBookingDemo 를 예약 안내 섹션 안에 넣었다. 날짜(2주) · 도착 시간 · 객실 · 인원을 고르면 확인 화면이 나오고 전화로 잇는다. 재고 조회도 접수도 결제도 없다 — PRODUCT.md 6절은 그대로다. 목업이라도 지킨 선: - 마감/잔여를 만들지 않는다. 모르는 값을 그럴듯하게 그리면 목업이 아니라 거짓말이다 - 시간 후보는 체크인 fact(16:00)에서 시작한다. fact 가 없으면 시간 선택을 내지 않는다 — 확인된 값과 어긋나는 선택지는 목업에도 두지 않는다 - 요금은 요금표·JSON-LD 와 같은 출처(unitBaseRate)를 쓴다. 한 페이지가 두 값을 말하지 않게 - 확인 화면은 "접수됐다"고 쓰지 않는다(사실이 아니다). 경고문도 두지 않는다(2026-09-09 결정) ★ 날짜는 브라우저에서 만든다(mounted 게이트). 서버에서 구우면 발행 시각의 날짜가 정적 HTML 에 박혀, 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는다 — 화면은 멀쩡하고 기계가 읽는 값만 틀리는 종류다. SSR 은 안내 한 줄만 내보낸다. 구조화 데이터·llms.txt 는 그대로다(availability 없음). 목업을 AI 에게 예약 창구로 소개하면 그때부터는 목업이 아니다. 연동을 붙일 자리는 ConfirmPanel 한 곳이다. tsc·eslint 통과, vitest 51 passed(신규 4). 발행본 재굽기 후 /s/ 확인. --- docs/DEVLOG.md | 35 ++ .../site/src/sections/StayBookingDemo.tsx | 378 ++++++++++++++++++ .../site/src/sections/StayBookingSection.tsx | 4 + solution/site/src/sections/index.ts | 1 + .../site/src/sections/stay-booking.test.tsx | 29 ++ 5 files changed, 447 insertions(+) create mode 100644 solution/site/src/sections/StayBookingDemo.tsx diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 66cce80..27b6462 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,41 @@ --- +## 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` 한 곳이다. 실시간 재고·접수가 생기면 그 함수만 바뀐다. + +**검증** — `tsc·eslint` 통과, `vitest` 51 passed(신규 4건: 날짜가 HTML 에 안 박히는지 · +JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후 +`/s/` 에서 데모 껍데기와 안내 문구 확인. + ## 2026-09-08 — "예약" 을 누르면 검색 화면이 떴다 — 네이버 예약 주소를 수집해서 쓴다 **무슨 일** diff --git a/solution/site/src/sections/StayBookingDemo.tsx b/solution/site/src/sections/StayBookingDemo.tsx new file mode 100644 index 0000000..35c98cb --- /dev/null +++ b/solution/site/src/sections/StayBookingDemo.tsx @@ -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(null); + useEffect(() => setDays(buildDays(new Date())), []); + + const [dateIso, setDateIso] = useState(null); + const [slot, setSlot] = useState(null); + const [unitId, setUnitId] = useState(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 ( +
+
+

+ + 날짜 · 시간 선택 +

+
+ + {/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */} + {days === null ? ( +

+ 날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서 + 확인해 주세요. +

+ ) : submitted ? ( + 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(' · ')} + /> + ) : ( +
+ {/* ── 날짜 ─────────────────────────────────────── */} +
+

날짜

+
    + {days.map((day) => { + const active = day.iso === dateIso; + return ( +
  • + +
  • + ); + })} +
+ {selectedDay?.isWeekend && ( +

주말 요금이 적용되는 날짜입니다.

+ )} +
+ + {/* ── 도착 시간 (체크인 fact 가 있을 때만) ───────── */} + {slots.length > 0 && ( +
+

+ + 도착 예정 시간 (체크인 {checkIn} 이후) +

+
    + {slots.map((time) => { + const active = time === slot; + return ( +
  • + +
  • + ); + })} +
+
+ )} + + {/* ── 객실 ─────────────────────────────────────── */} +
+

객실

+
    + {units.map((unit) => { + const active = unit.unitId === unitId; + return ( +
  • + +
  • + ); + })} +
+
+ + {/* ── 인원 ─────────────────────────────────────── */} +
+

인원

+
+ + {guests}명 + +
+
+ + {/* ── 요약 · 요청 ──────────────────────────────── */} +
+
+ + {selectedDay + ? `${selectedDay.month}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명` + : '날짜를 골라 주세요'} + + {price != null && ( + + {price.toLocaleString('ko-KR')}원 + + )} +
+ {/* 요금은 확인된 요금 fact 를 그대로 읽은 값이지, 견적이 아니다. */} + {price != null && ( +

+ 1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다. +

+ )} +
+ + +
+ )} +
+ ); +} + +/** + * 확인 화면 — 고른 내용을 보여주고 예약 창구로 잇는다. + * + * ★ "접수됐다" 고 쓰지 않는다. 어디에도 보내지 않으므로 그건 사실이 아니고, 목업이라도 + * 화면에 없는 일을 일어난 것처럼 적으면 그때부터는 목업이 아니라 거짓말이다. + * 반대로 "접수되지 않았다" 는 안내도 두지 않는다(2026-09-09 결정) — 흐름만 보여주는 + * 화면이라 경고문이 오히려 흐름을 가린다. 그래서 **선택 내용 확인**까지만 말한다. + */ +function ConfirmPanel({ + payload, + summary, + onReset, +}: { + payload: SitePayload; + summary: string; + onReset: () => void; +}) { + const phone = payload.place.phone; + + return ( +
+
+ + + +
+

예약 내용 확인

+

{summary}

+
+
+ +
+ {phone && ( + + + 전화로 예약하기 {phone} + + )} + +
+
+ ); +} diff --git a/solution/site/src/sections/StayBookingSection.tsx b/solution/site/src/sections/StayBookingSection.tsx index f498430..ea1def4 100644 --- a/solution/site/src/sections/StayBookingSection.tsx +++ b/solution/site/src/sections/StayBookingSection.tsx @@ -1,6 +1,7 @@ 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'; /** * 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리. @@ -174,6 +175,9 @@ export function StayBookingSection() { + {/* ── 날짜 · 시간 선택 (화면 안에서만 도는 목업) ────────── */} + + {/* ── 예약 전 확인 ────────────────────────────────────── */} {notices.length > 0 && (
{ }); }); +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[]) { + 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( From c43c4f36209b3db32606b2d513f6ecbe826a8e86 Mon Sep 17 00:00:00 2001 From: hbyang Date: Wed, 9 Sep 2026 10:37:17 +0900 Subject: [PATCH 6/6] =?UTF-8?q?[fix]=20solution/frontend:=20=EB=B9=8C?= =?UTF-8?q?=EB=8D=94=20=EC=BA=94=EB=B2=84=EC=8A=A4=EC=9D=98=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EC=84=B9=EC=85=98=EC=9D=84=20=EB=B0=9C=ED=96=89?= =?UTF-8?q?=EB=B3=B8=EA=B3=BC=20=EB=A7=9E=EC=B6=98=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=98=88=EC=95=BD=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 편집 화면이 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약하실 수 있습니다" 를 그리고 있었다. 우리는 실시간 재고를 갖지 않고(PRODUCT.md 6절), 발행본은 날짜·시간을 고르는 화면이다 — 사장님이 에디터에서 본 것과 발행된 사이트가 서로 다른 물건이었다. 에디터가 보여주는 것이 곧 발행될 것이어야 한다. - booking/BookingCard: 발행본 구성(날짜 칩·도착 시간·인원·예약 요청·전화 창구)의 미리보기로 교체. 캔버스 클릭은 섹션 선택이라 상태를 두지 않고 첫 칸 선택 모습으로 고정. 시간 칸은 발행본과 같은 규칙 — 체크인 fact 가 있을 때만 그린다 - booking/BookingBanner: "실시간 캘린더에서 남은 날짜" → "날짜와 시간을 고르고 예약 창구로" - rooms/RoomCard: "실시간 예약 신청" → "예약 안내 보기" - hero/HeroEditorial: "실시간 예약" → "예약 안내" - api/generated linkChannel: NAVER_BOOKING=7 추가. ★ npm run orval 을 그대로 돌리면 141파일 6,400줄이 바뀌는데 전부 따옴표·줄바꿈 포매팅 드리프트다 — 생성물을 되돌리고 스펙 변경분 한 줄만 남겼다 tsc·eslint 통과(frontend·site), vitest 51 passed. solution-site 재빌드 후 번들에서 옛 문구 0건 확인. --- docs/DEVLOG.md | 14 ++ .../src/api/generated/model/linkChannel.ts | 1 + .../model/placeSearchItemNaverPlaceUrl.ts | 8 + .../canvas/variants/booking/BookingBanner.tsx | 8 +- .../canvas/variants/booking/BookingCard.tsx | 150 +++++++++++++++--- .../canvas/variants/hero/HeroEditorial.tsx | 2 +- .../canvas/variants/rooms/RoomCard.tsx | 4 +- 7 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 solution/frontend/src/api/generated/model/placeSearchItemNaverPlaceUrl.ts diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 27b6462..3f2d834 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -36,6 +36,20 @@ **연동을 붙일 자리** — `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/` 에서 데모 껍데기와 안내 문구 확인. diff --git a/solution/frontend/src/api/generated/model/linkChannel.ts b/solution/frontend/src/api/generated/model/linkChannel.ts index 58cf6b4..6ae5e7d 100644 --- a/solution/frontend/src/api/generated/model/linkChannel.ts +++ b/solution/frontend/src/api/generated/model/linkChannel.ts @@ -19,5 +19,6 @@ export const LinkChannel = { INSTAGRAM: 4, OFFICIAL_SITE: 5, BLOG: 6, + NAVER_BOOKING: 7, ETC: 99, } as const; diff --git a/solution/frontend/src/api/generated/model/placeSearchItemNaverPlaceUrl.ts b/solution/frontend/src/api/generated/model/placeSearchItemNaverPlaceUrl.ts new file mode 100644 index 0000000..8e7a6d9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/placeSearchItemNaverPlaceUrl.ts @@ -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; diff --git a/solution/frontend/src/features/builder/canvas/variants/booking/BookingBanner.tsx b/solution/frontend/src/features/builder/canvas/variants/booking/BookingBanner.tsx index 1db4c76..a288c1c 100644 --- a/solution/frontend/src/features/builder/canvas/variants/booking/BookingBanner.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/booking/BookingBanner.tsx @@ -1,6 +1,10 @@ /** * 예약 · 띠배너 — 브랜드 색으로 화면을 가로지르는 강한 전환 유도. * 예약이 유일한 목표인 사이트에 맞는다(대신 자주 쓰면 광고처럼 보인다). + * + * ★ 문구에서 "실시간 캘린더" 를 걷어냈다(2026-09-09). 우리는 실시간 재고를 갖지 않는다 + * (PRODUCT.md 6절). 발행본이 하는 일 그대로 — 날짜·시간을 고르고 예약 창구로 잇는다 — + * 을 말한다. 에디터가 보여주는 것이 곧 발행될 것이어야 한다. */ import {Calendar} from 'lucide-react'; import {CtaLink, SectionFrame} from '../../primitives'; @@ -19,14 +23,14 @@ export function BookingBanner(props: SectionRenderProps) { {section.name}

- {section.body || section.description || `${storeName} · 실시간 캘린더에서 남은 날짜를 확인할 수 있습니다`} + {section.body || section.description || `${storeName} · 날짜와 시간을 고르고 예약 창구로 이어집니다`}

- 예약 캘린더 + 날짜 · 시간 고르기
diff --git a/solution/frontend/src/features/builder/canvas/variants/booking/BookingCard.tsx b/solution/frontend/src/features/builder/canvas/variants/booking/BookingCard.tsx index 1dd1ee3..e82fc5a 100644 --- a/solution/frontend/src/features/builder/canvas/variants/booking/BookingCard.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/booking/BookingCard.tsx @@ -1,34 +1,146 @@ /** - * 예약 · 카드 — 가운데 놓인 조용한 안내 카드. 다른 섹션의 흐름을 끊지 않는다. + * 예약 · 카드 — **발행본의 예약 안내를 그대로 미리 보여준다.** + * + * ★ 왜 갈아엎었나 (2026-09-09) + * 이 카드는 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약" 을 그리고 있었다. + * 우리는 실시간 예약을 하지 않는다(PRODUCT.md 6절 — 재고도 결제도 갖지 않는다). + * 게다가 발행본은 날짜·시간을 고르는 화면인데 캔버스만 다른 문구를 보여줘서, + * 사장님이 편집 화면에서 본 것과 발행된 사이트가 서로 다른 물건이었다. + * **에디터가 보여주는 것이 곧 발행될 것**이어야 한다. + * + * ★ 여기서는 **누르지 않는다.** 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라 + * 상태를 가진 위젯을 넣으면 선택과 싸운다. 첫 칸이 골라진 모습으로 고정해 두고, + * 실제 동작은 발행본(`solution/site` StayBookingDemo)이 한다. + * + * ★ 시간 칸은 **체크인 fact 가 있을 때만** 그린다. 발행본과 같은 규칙이다 — + * 체크인이 16:00 인데 미리보기가 14:00 을 보여주면 사장님은 그 선택지가 생긴 줄 안다. */ -import {Calendar} from 'lucide-react'; -import {CtaLink, SectionBody, SectionFrame, SectionHeading} from '../../primitives'; +import {CalendarDays, Clock, Minus, Phone, Plus} from 'lucide-react'; +import type {InfoField} from '@o2o/shared'; +import {SectionBody, SectionFrame, SectionHeading} from '../../primitives'; 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) { - 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 ( -
-

- 네이버 실시간 온라인 예약 -

-

- {section.body || '원하시는 날짜와 객실을 선택하여 캘린더에서 바로 확정 예약하실 수 있습니다.'} +

+

+ + 날짜 · 시간 선택

- - - 네이버 예약 캘린더 열기 - + +
+
+

날짜

+
    + {days.map((day, index) => ( +
  • + {day.weekday} + {day.day} +
  • + ))} +
+
+ + {slots.length > 0 && ( +
+

+ + 도착 예정 시간 (체크인 {checkIn} 이후) +

+
    + {slots.map((time, index) => ( +
  • + {time} +
  • + ))} +
+
+ )} + +
+

인원

+
+ + + + 2명 + + + +
+
+ +
+ 예약 요청 확인하기 +
+ + {/* ★ 실제 예약이 되는 창구. 발행본과 같은 순서다 — 데모 다음에 진짜 창구가 온다. */} + {phone && ( +

+ + 전화 예약 {phone} +

+ )} +
diff --git a/solution/frontend/src/features/builder/canvas/variants/hero/HeroEditorial.tsx b/solution/frontend/src/features/builder/canvas/variants/hero/HeroEditorial.tsx index 6c16470..868cc5e 100644 --- a/solution/frontend/src/features/builder/canvas/variants/hero/HeroEditorial.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/hero/HeroEditorial.tsx @@ -55,7 +55,7 @@ export function HeroEditorial(props: SectionRenderProps) { colors={template.colors} > - 실시간 예약 + 예약 안내
diff --git a/solution/frontend/src/features/builder/canvas/variants/rooms/RoomCard.tsx b/solution/frontend/src/features/builder/canvas/variants/rooms/RoomCard.tsx index c983f54..88e50cb 100644 --- a/solution/frontend/src/features/builder/canvas/variants/rooms/RoomCard.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/rooms/RoomCard.tsx @@ -129,7 +129,9 @@ export function RoomCard({ const action = (
- 실시간 예약 신청 + {/* ★ "실시간 예약 신청" 이었다(2026-09-09 수정). 실시간 재고를 갖지 않으므로 + 누르면 예약이 확정되는 것처럼 읽히면 안 된다 — 발행본과 같은 말로 맞춘다. */} + 예약 안내 보기