o2o-site-AEO/solution/site/src/sections/StayBookingDemo.tsx
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00

485 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useEffect, useMemo, useState} from 'react';
import {BookingRequestSection} from './BookingRequestSection';
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;
}
/** "B동(모던한 현대식 컨셉으로 꾸며진 따뜻한 공간)" → "B동" — 날짜·인원과 한 줄에 묶일 때만 줄인다. */
function shortUnitName(name: string): string {
return name.replace(/\s*\(.*\)\s*$/, '').trim() || name;
}
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<Date | null>(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<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);
/* ★ 달력은 접어 둔다. 3.5화면짜리 섹션의 절반이 달력이었는데, 손님 대부분은 예약 창구
버튼을 누르러 온다 — 날짜를 고르려는 사람만 펼친다. */
const [pickerOpen, setPickerOpen] = 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 (
<div
className="mt-4 overflow-hidden rounded-2xl border border-black/8 lg:mt-6"
style={{backgroundColor: 'var(--color-surface)'}}
>
<button
type="button"
onClick={() => setPickerOpen((value) => !value)}
aria-expanded={pickerOpen}
className="tap flex w-full items-center gap-2 border-b border-black/8 px-4 text-[length:var(--fs-sm)] font-bold sm:px-5"
>
<CalendarDays className="size-4 opacity-100" />
<span className="flex-1 text-left">날짜 · 시간 선택</span>
<span className="opacity-100">{pickerOpen ? '−' : '+'}</span>
</button>
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
{today === null || cursor === null ? (
<p className="px-4 py-6 text-[length:var(--fs-xs)] leading-relaxed opacity-100 sm:px-5">
날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서
확인해 주세요.
</p>
) : !pickerOpen && !submitted ? null : submitted ? (
<ConfirmPanel
payload={payload}
onReset={() => setSubmitted(false)}
stay={[
selectedDay ? `${cursor.getMonth() + 1}월 ${selectedDay.day}일(${WEEKDAY_LABEL[selectedDay.weekday]})` : null,
slot ? `도착 ${slot}` : null,
selectedUnit ? shortUnitName(selectedUnit.name) : null,
].filter(Boolean).join(' · ')}
guests={`${guests}명`}
summary={[
selectedDay ? `${cursor.getMonth() + 1}월 ${selectedDay.day}일(${WEEKDAY_LABEL[selectedDay.weekday]})` : null,
slot ? `도착 ${slot}` : null,
selectedUnit ? shortUnitName(selectedUnit.name) : null,
`${guests}명`,
]
.filter((part): part is string => Boolean(part))
.join(' · ')}
/>
) : (
<div className="space-y-5 p-4 sm:p-5">
{/* ── 날짜 (달력) ──────────────────────────────── */}
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-[length:var(--fs-xs)] font-semibold opacity-100">날짜</p>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setMonthOffset((n) => Math.max(0, n - 1))}
disabled={monthOffset === 0}
aria-label="이전 달"
className="flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronLeft className="size-3.5" />
</button>
<span className="w-24 text-center text-[length:var(--fs-xs)] font-bold">
{cursor.getFullYear()}년 {cursor.getMonth() + 1}월
</span>
<button
type="button"
onClick={() => setMonthOffset((n) => Math.min(MONTH_SPAN, n + 1))}
disabled={monthOffset >= MONTH_SPAN}
aria-label="다음 달"
className="flex size-7 items-center justify-center rounded-lg border border-black/10 transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronRight className="size-3.5" />
</button>
</div>
</div>
{/* 요일 머리. 토·일은 색으로 구분한다 — 주말 요금이 붙는 날이라 정보다. */}
<div className="grid grid-cols-7 gap-1 border-b border-black/8 pb-1.5">
{WEEKDAY_LABEL.map((label, index) => (
<span
key={label}
className="text-center text-[length:var(--fs-xs)] font-semibold"
style={{opacity: index === 0 || index === 6 ? 0.75 : 0.45}}
>
{label}
</span>
))}
</div>
<div className="mt-1.5 grid grid-cols-7 gap-1">
{cells.map((cell, index) =>
cell === null ? (
// 1일 앞의 빈 칸. 요일 열을 맞추는 자리라 버튼이 아니다.
<span key={`pad-${index}`} aria-hidden />
) : (
<button
key={cell.iso}
type="button"
disabled={cell.past}
onClick={() => setDateIso(cell.iso)}
aria-pressed={cell.iso === dateIso}
aria-label={`${cursor.getMonth() + 1}월 ${cell.day}일`}
className="flex h-9 items-center justify-center rounded-lg border text-[length:var(--fs-xs)] transition-colors disabled:cursor-not-allowed"
style={{
borderColor: cell.iso === dateIso ? 'var(--color-brand)' : 'transparent',
backgroundColor:
cell.iso === dateIso ? 'var(--color-brand)' : 'var(--color-surface-alt)',
color: cell.iso === dateIso ? '#fff' : undefined,
// ★ 지난 날짜만 흐리다. '마감'·'잔여' 는 만들지 않는다 — 우리는 그 값을 모른다.
opacity: cell.past ? 0.25 : 1,
fontWeight: cell.iso === dateIso ? 700 : 400,
}}
>
{cell.day}
</button>
),
)}
</div>
{selectedDay?.isWeekend && (
<p className="mt-2 text-[length:var(--fs-xs)] opacity-100">주말 요금이 적용되는 날짜입니다.</p>
)}
</div>
{/* ── 도착 시간 (체크인 fact 가 있을 때만) ───────── */}
{slots.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-[length:var(--fs-xs)] font-semibold opacity-100">
<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-[length:var(--fs-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-[length:var(--fs-xs)] font-semibold opacity-100">객실</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-[length:var(--fs-xs)] transition-colors"
style={{
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
backgroundColor: 'var(--color-surface-alt)',
}}
>
<span className="min-w-0 truncate font-semibold">{unit.name}</span>
{unit.maxCapacity && (
<span className="shrink-0 opacity-100">최대 {unit.maxCapacity}명</span>
)}
</button>
</li>
);
})}
</ul>
</div>
{/* ── 인원 ─────────────────────────────────────── */}
<div className="flex items-center justify-between">
<p className="text-[length:var(--fs-xs)] font-semibold opacity-100">인원</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-[length:var(--fs-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-[length:var(--fs-xs)] opacity-100">
{selectedDay
? `${cursor.getMonth() + 1}월 ${selectedDay.day}일 · ${selectedUnit ? shortUnitName(selectedUnit.name) : ''} · ${guests}명`
: '날짜를 골라 주세요'}
</span>
{price != null && (
<span className="text-[length:var(--fs-sm)] font-bold" style={{color: 'var(--color-brand)'}}>
{price.toLocaleString('ko-KR')}원
</span>
)}
</div>
{/* 요금은 확인된 요금 fact 를 그대로 읽은 값이지, 견적이 아니다. */}
{price != null && (
<p className="mt-1 text-[length:var(--fs-xs)] opacity-100">
1박 기준 안내 요금입니다. 인원 추가·성수기 요금은 예약 창구에서 확인됩니다.
</p>
)}
</div>
<button
type="button"
disabled={!ready}
onClick={() => setSubmitted(true)}
className="w-full rounded-xl px-4 py-3 text-[length:var(--fs-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,
stay,
guests,
onReset,
}: {
payload: SitePayload;
summary: string;
stay: string;
guests: 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-[length:var(--fs-sm)] font-bold">예약 내용 확인</p>
<p className="mt-0.5 text-[length:var(--fs-xs)] leading-relaxed opacity-100">{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-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-100"
style={{backgroundColor: 'var(--color-brand)'}}
>
<Phone className="size-4" />
<span>전화로 예약하기 {phone}</span>
</a>
)}
<button
type="button"
onClick={onReset}
className="tap flex items-center justify-center gap-1.5 rounded-xl border border-black/10 px-4 text-[length:var(--fs-sm)] font-semibold transition-colors hover:bg-black/5"
>
<RotateCcw className="size-4" />
<span>다시 고르기</span>
</button>
</div>
{/* 고른 내용을 그대로 물고 연락처만 받는다 — 같은 값을 두 번 묻지 않는다. */}
<BookingRequestSection stay={stay} guests={guests} />
</div>
);
}