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(