import {useEffect, useMemo, useState} from 'react'; import {CalendarDays, Check, ChevronLeft, ChevronRight, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-react'; import {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared'; import {useSite} from '@site/lib/site-context'; import {unitBaseRate} from '@site/seo/jsonld'; /** * 예약 데모 — **화면 안에서만 도는 목업이다.** * * ★ 무엇이 아닌가 * 빈 방 재고를 조회하지 않고, 어디에도 접수하지 않으며, 결제도 없다. 네이버 예약·OTA 와 * 연동되어 있지 않다(PRODUCT.md 6절 — "사이트는 예약 채널로 보낸다. 거래를 품지 않는다"). * 흐름을 눈으로 보기 위한 구성이다. * * ★ 마지막 화면은 고른 내용을 확인해 주고 **진짜 창구(전화)로 잇는다.** * "접수됐다" 고 쓰지 않는다 — 어디에도 보내지 않으므로 사실이 아니다. 반대로 "접수되지 * 않았다" 는 경고도 두지 않는다(2026-09-09 결정): 흐름을 보여주는 화면이라 경고문이 * 흐름을 가린다. 연동을 붙일 자리는 ConfirmPanel 한 곳이다. * * ★ 날짜는 **브라우저에서** 만든다(mounted 게이트). * 프리렌더가 서버에서 날짜를 구우면 발행 시각의 날짜가 정적 HTML 에 박힌다 — 한 달 뒤 * 크롤러가 그 페이지를 읽으면 지난 날짜가 예약 가능일로 적혀 있다. 그건 조용히 거짓이 * 되는 종류라, 서버 렌더에서는 안내만 내보내고 달력은 하이드레이션 후에 그린다. * * ★ "마감/잔여" 같은 표시를 만들지 않는다. 우리는 그 값을 모른다 — 그럴듯하게 지어내면 * 목업이 아니라 거짓말이 된다. 고를 수 없는 날은 **지난 날짜**뿐이고, 그건 사실이다. */ const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const; /** * 고를 수 있는 앞선 달 수. 오늘이 든 달부터 이만큼 앞으로 넘길 수 있다. * * ★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤를 고르게 두면 손님은 그 날짜가 * 열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다. */ const MONTH_SPAN = 2; interface DayCell { iso: string; day: number; weekday: number; /** 토·일은 주말 요금이 붙는 날이다. 요금 계산의 근거가 화면에도 보여야 한다. */ isWeekend: boolean; /** 지난 날짜. **고를 수 없는 유일한 사유**다 — '마감' 같은 표시는 만들지 않는다(머리주석). */ past: boolean; } function isoOf(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } /** * 한 달치 칸. 1일이 무슨 요일인지에 따라 앞에 빈 칸을 채워 **요일 열을 맞춘다** — * 달력은 열이 맞아야 달력이고, 어긋나면 그냥 숫자 목록이다. */ function buildMonth(year: number, month: number, today: Date): (DayCell | null)[] { const first = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0).getDate(); const todayIso = isoOf(today); const cells: (DayCell | null)[] = Array.from({length: first.getDay()}, () => null); for (let day = 1; day <= lastDay; day += 1) { const date = new Date(year, month, day); const iso = isoOf(date); cells.push({ iso, day, weekday: date.getDay(), isWeekend: date.getDay() === 0 || date.getDay() === 6, past: iso < todayIso, }); } return cells; } /** * 도착 시간 후보. **체크인 시간 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]); /** * ★ 서버 렌더에서는 null — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석). * '오늘' 을 브라우저에서 정하므로 이 값이 곧 달력의 기준이다. */ const [today, setToday] = useState(null); const [monthOffset, setMonthOffset] = useState(0); useEffect(() => setToday(new Date()), []); const cursor = useMemo( () => (today ? new Date(today.getFullYear(), today.getMonth() + monthOffset, 1) : null), [today, monthOffset], ); const cells = useMemo( () => (today && cursor ? buildMonth(cursor.getFullYear(), cursor.getMonth(), today) : []), [today, cursor], ); 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 = cells.find((cell): cell is DayCell => cell !== null && cell.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 (

날짜 · 시간 선택

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

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

) : submitted ? ( setSubmitted(false)} summary={[ selectedDay ? `${cursor.getMonth() + 1}월 ${selectedDay.day}일(${WEEKDAY_LABEL[selectedDay.weekday]})` : null, slot ? `도착 ${slot}` : null, selectedUnit?.name ?? null, `${guests}명`, ] .filter((part): part is string => Boolean(part)) .join(' · ')} /> ) : (
{/* ── 날짜 (달력) ──────────────────────────────── */}

날짜

{cursor.getFullYear()}년 {cursor.getMonth() + 1}월
{/* 요일 머리. 토·일은 색으로 구분한다 — 주말 요금이 붙는 날이라 정보다. */}
{WEEKDAY_LABEL.map((label, index) => ( {label} ))}
{cells.map((cell, index) => cell === null ? ( // 1일 앞의 빈 칸. 요일 열을 맞추는 자리라 버튼이 아니다. ) : ( ), )}
{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 ? `${cursor.getMonth() + 1}월 ${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} )}
); }