인앱 미니 블로그(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에서 보정).
485 lines
22 KiB
TypeScript
485 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|