[feat] solution/site: 예약 안내 안에 날짜·시간 목업 — 연동 없이 화면에서만 돈다
예약 흐름을 눈으로 보려고 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/<slug> 확인.
This commit is contained in:
parent
66f81f3631
commit
0b33f035ef
@ -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/<slug>` 에서 데모 껍데기와 안내 문구 확인.
|
||||||
|
|
||||||
## 2026-09-08 — "예약" 을 누르면 검색 화면이 떴다 — 네이버 예약 주소를 수집해서 쓴다
|
## 2026-09-08 — "예약" 을 누르면 검색 화면이 떴다 — 네이버 예약 주소를 수집해서 쓴다
|
||||||
|
|
||||||
**무슨 일**
|
**무슨 일**
|
||||||
|
|||||||
378
solution/site/src/sections/StayBookingDemo.tsx
Normal file
378
solution/site/src/sections/StayBookingDemo.tsx
Normal file
@ -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<DayCell[] | null>(null);
|
||||||
|
useEffect(() => setDays(buildDays(new Date())), []);
|
||||||
|
|
||||||
|
const [dateIso, setDateIso] = useState<string | null>(null);
|
||||||
|
const [slot, setSlot] = useState<string | null>(null);
|
||||||
|
const [unitId, setUnitId] = useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6"
|
||||||
|
style={{backgroundColor: 'var(--color-surface)'}}
|
||||||
|
>
|
||||||
|
<div className="border-b border-black/8 px-4 py-3 sm:px-5">
|
||||||
|
<p className="flex items-center gap-2 text-xs font-bold">
|
||||||
|
<CalendarDays className="size-3.5 opacity-50" />
|
||||||
|
<span>날짜 · 시간 선택</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
|
||||||
|
{days === null ? (
|
||||||
|
<p className="px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5">
|
||||||
|
날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서
|
||||||
|
확인해 주세요.
|
||||||
|
</p>
|
||||||
|
) : submitted ? (
|
||||||
|
<ConfirmPanel
|
||||||
|
payload={payload}
|
||||||
|
onReset={() => 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(' · ')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5 p-4 sm:p-5">
|
||||||
|
{/* ── 날짜 ─────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold opacity-55">날짜</p>
|
||||||
|
<ul className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
|
||||||
|
{days.map((day) => {
|
||||||
|
const active = day.iso === dateIso;
|
||||||
|
return (
|
||||||
|
<li key={day.iso}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDateIso(day.iso)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className="flex w-13 shrink-0 flex-col items-center gap-0.5 rounded-xl border px-2 py-2 text-xs transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
|
||||||
|
backgroundColor: active ? 'var(--color-brand)' : 'var(--color-surface-alt)',
|
||||||
|
color: active ? '#fff' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] opacity-70">{WEEKDAY_LABEL[day.weekday]}</span>
|
||||||
|
<span className="font-bold">{day.day}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{selectedDay?.isWeekend && (
|
||||||
|
<p className="mt-1.5 text-[11px] opacity-55">주말 요금이 적용되는 날짜입니다.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 도착 시간 (체크인 fact 가 있을 때만) ───────── */}
|
||||||
|
{slots.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold opacity-55">
|
||||||
|
<Clock className="size-3" />
|
||||||
|
<span>도착 예정 시간 (체크인 {checkIn} 이후)</span>
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-wrap gap-1.5">
|
||||||
|
{slots.map((time) => {
|
||||||
|
const active = time === slot;
|
||||||
|
return (
|
||||||
|
<li key={time}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSlot(time)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className="rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
|
||||||
|
backgroundColor: active ? 'var(--color-brand)' : 'var(--color-surface-alt)',
|
||||||
|
color: active ? '#fff' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{time}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 객실 ─────────────────────────────────────── */}
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-[11px] font-semibold opacity-55">객실</p>
|
||||||
|
<ul className="grid gap-1.5 sm:grid-cols-2">
|
||||||
|
{units.map((unit) => {
|
||||||
|
const active = unit.unitId === unitId;
|
||||||
|
return (
|
||||||
|
<li key={unit.unitId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnitId(unit.unitId)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className="flex w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5 text-left text-xs transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
|
||||||
|
backgroundColor: 'var(--color-surface-alt)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-semibold">{unit.name}</span>
|
||||||
|
{unit.maxCapacity && (
|
||||||
|
<span className="shrink-0 opacity-55">최대 {unit.maxCapacity}명</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 인원 ─────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[11px] font-semibold opacity-55">인원</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGuests((n) => Math.max(1, n - 1))}
|
||||||
|
aria-label="인원 줄이기"
|
||||||
|
className="flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="w-10 text-center text-sm font-bold">{guests}명</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGuests((n) => Math.min(maxGuests, n + 1))}
|
||||||
|
aria-label="인원 늘리기"
|
||||||
|
className="flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 요약 · 요청 ──────────────────────────────── */}
|
||||||
|
<div className="rounded-xl border border-black/8 p-3" style={{backgroundColor: 'var(--color-surface-alt)'}}>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-xs opacity-60">
|
||||||
|
{selectedDay
|
||||||
|
? `${selectedDay.month}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명`
|
||||||
|
: '날짜를 골라 주세요'}
|
||||||
|
</span>
|
||||||
|
{price != null && (
|
||||||
|
<span className="text-sm font-bold" style={{color: 'var(--color-brand)'}}>
|
||||||
|
{price.toLocaleString('ko-KR')}원
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 요금은 확인된 요금 fact 를 그대로 읽은 값이지, 견적이 아니다. */}
|
||||||
|
{price != null && (
|
||||||
|
<p className="mt-1 text-[11px] opacity-50">
|
||||||
|
1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!ready}
|
||||||
|
onClick={() => setSubmitted(true)}
|
||||||
|
className="w-full rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
style={{backgroundColor: 'var(--color-brand)'}}
|
||||||
|
>
|
||||||
|
예약 요청 확인하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확인 화면 — 고른 내용을 보여주고 예약 창구로 잇는다.
|
||||||
|
*
|
||||||
|
* ★ "접수됐다" 고 쓰지 않는다. 어디에도 보내지 않으므로 그건 사실이 아니고, 목업이라도
|
||||||
|
* 화면에 없는 일을 일어난 것처럼 적으면 그때부터는 목업이 아니라 거짓말이다.
|
||||||
|
* 반대로 "접수되지 않았다" 는 안내도 두지 않는다(2026-09-09 결정) — 흐름만 보여주는
|
||||||
|
* 화면이라 경고문이 오히려 흐름을 가린다. 그래서 **선택 내용 확인**까지만 말한다.
|
||||||
|
*/
|
||||||
|
function ConfirmPanel({
|
||||||
|
payload,
|
||||||
|
summary,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
payload: SitePayload;
|
||||||
|
summary: string;
|
||||||
|
onReset: () => void;
|
||||||
|
}) {
|
||||||
|
const phone = payload.place.phone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4 sm:p-5">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<span
|
||||||
|
className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-white"
|
||||||
|
style={{backgroundColor: 'var(--color-brand)'}}
|
||||||
|
>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-bold">예약 내용 확인</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed opacity-70">{summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
{phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${phone}`}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-bold text-white transition-opacity hover:opacity-90"
|
||||||
|
style={{backgroundColor: 'var(--color-brand)'}}
|
||||||
|
>
|
||||||
|
<Phone className="size-4" />
|
||||||
|
<span>전화로 예약하기 {phone}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReset}
|
||||||
|
className="flex items-center justify-center gap-1.5 rounded-xl border border-black/10 px-4 py-3 text-xs font-semibold transition-colors hover:bg-black/5"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3.5" />
|
||||||
|
<span>다시 고르기</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react';
|
import {ArrowUpRight, BedDouble, CalendarCheck, Phone, ShieldCheck} from 'lucide-react';
|
||||||
import {useSite} from '@/lib/site-context';
|
import {useSite} from '@/lib/site-context';
|
||||||
import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@/lib/derive';
|
import {bookingCtaLabel, channelLabel, sectionName, stayBookingView} from '@/lib/derive';
|
||||||
|
import {StayBookingDemo} from './StayBookingDemo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리.
|
* 숙박 예약 구성 — 손님이 **이 방을 · 이 값에 · 이 창구로** 예약할 수 있는 자리.
|
||||||
@ -174,6 +175,9 @@ export function StayBookingSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── 날짜 · 시간 선택 (화면 안에서만 도는 목업) ────────── */}
|
||||||
|
<StayBookingDemo />
|
||||||
|
|
||||||
{/* ── 예약 전 확인 ────────────────────────────────────── */}
|
{/* ── 예약 전 확인 ────────────────────────────────────── */}
|
||||||
{notices.length > 0 && (
|
{notices.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export {RulesSection} from './RulesSection';
|
|||||||
export {BookingSection} from './BookingSection';
|
export {BookingSection} from './BookingSection';
|
||||||
// 숙박은 예약 구성이 통째로 다르다 — 이유는 StayBookingSection 머리주석.
|
// 숙박은 예약 구성이 통째로 다르다 — 이유는 StayBookingSection 머리주석.
|
||||||
export {StayBookingSection} from './StayBookingSection';
|
export {StayBookingSection} from './StayBookingSection';
|
||||||
|
export {StayBookingDemo} from './StayBookingDemo';
|
||||||
export {SpaceSection} from './SpaceSection';
|
export {SpaceSection} from './SpaceSection';
|
||||||
export {InquirySection} from './InquirySection';
|
export {InquirySection} from './InquirySection';
|
||||||
export {ExhibitionSection} from './ExhibitionSection';
|
export {ExhibitionSection} from './ExhibitionSection';
|
||||||
|
|||||||
@ -253,6 +253,35 @@ describe('예약 버튼은 예약 화면으로 바로 간다', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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<string, unknown>[]) {
|
||||||
|
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 가 예약 경로를 읽는 자리', () => {
|
describe('llms.txt — AI 가 예약 경로를 읽는 자리', () => {
|
||||||
it('재고와 결제를 우리가 갖지 않는다고 명시한다', () => {
|
it('재고와 결제를 우리가 갖지 않는다고 명시한다', () => {
|
||||||
expect(renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD)).toContain(
|
expect(renderLlmsTxt(MOONLIGHT_STAY_PAYLOAD)).toContain(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user