From 868127a69a232d8062016c7bcd6dc79a6e77ec28 Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 10 Sep 2026 15:24:21 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/site:=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=AA=A9=EC=97=85=EC=9D=98=20=EB=82=A0=EC=A7=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=AC=EB=A0=A5=EC=9C=BC=EB=A1=9C=20=E2=80=94=2014=EC=B9=B8?= =?UTF-8?q?=20=EC=8A=A4=ED=8A=B8=EB=A6=BD=EC=9D=84=20=EA=B1=B7=EC=96=B4?= =?UTF-8?q?=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 날짜를 한 줄로 펼쳐 놓으니 예약 화면으로 안 읽혔다. 두 주만 보이는 것도 문제였다 — 손님이 다음 달을 잡으려면 방법이 없었다. - 월 단위 그리드로 바꿨다. 요일 머리(일~토)를 두고 1일 앞을 빈 칸으로 채워 **열을 맞춘다** — 달력은 열이 맞아야 달력이고, 어긋나면 그냥 숫자 목록이다 - 이전·다음 달. 오늘이 든 달이 하한이고 앞으로 두 달까지다(MONTH_SPAN). ★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤까지 열어 두면 손님은 그 날짜가 열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다 - 토·일은 요일 머리와 함께 구분한다. 장식이 아니라 주말 요금이 붙는 날이라는 정보다 - **고를 수 없는 날은 지난 날짜뿐이다.** '마감'·'잔여' 는 여전히 만들지 않는다 — 우리는 그 값을 모르고, 지어내면 손님이 그걸 보고 다른 날을 고른다 ★ 서버 렌더 게이트는 그대로다. '오늘' 을 브라우저에서 정하므로 정적 HTML 에는 달력이 굽히지 않는다 — 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는 일이 없다. site tsc·eslint 통과 · vitest 51 passed(SSR 이 날짜를 굽지 않는지 보는 기존 검사 포함). --- .../site/src/sections/StayBookingDemo.tsx | 178 +++++++++++++----- 1 file changed, 129 insertions(+), 49 deletions(-) diff --git a/solution/site/src/sections/StayBookingDemo.tsx b/solution/site/src/sections/StayBookingDemo.tsx index c577e87..b5069e9 100644 --- a/solution/site/src/sections/StayBookingDemo.tsx +++ b/solution/site/src/sections/StayBookingDemo.tsx @@ -1,5 +1,5 @@ import {useEffect, useMemo, useState} from 'react'; -import {CalendarDays, Check, Clock, Minus, Phone, Plus, RotateCcw} from 'lucide-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'; @@ -26,31 +26,52 @@ import {unitBaseRate} from '@site/seo/jsonld'; * 목업이 아니라 거짓말이 된다. 고를 수 없는 날은 **지난 날짜**뿐이고, 그건 사실이다. */ -/** 달력에 낼 날짜 수. 두 주면 흐름을 보기에 충분하고, 화면도 한 줄에 들어온다. */ -const DAY_COUNT = 14; const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const; +/** + * 고를 수 있는 앞선 달 수. 오늘이 든 달부터 이만큼 앞으로 넘길 수 있다. + * + * ★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤를 고르게 두면 손님은 그 날짜가 + * 열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다. + */ +const MONTH_SPAN = 2; + interface DayCell { iso: string; - month: number; day: number; weekday: number; /** 토·일은 주말 요금이 붙는 날이다. 요금 계산의 근거가 화면에도 보여야 한다. */ isWeekend: boolean; + /** 지난 날짜. **고를 수 없는 유일한 사유**다 — '마감' 같은 표시는 만들지 않는다(머리주석). */ + past: 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, - }; - }); +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; } /** @@ -106,9 +127,22 @@ export function StayBookingDemo() { ); const slots = useMemo(() => buildArrivalSlots(checkIn), [checkIn]); - /** ★ 서버 렌더에서는 false — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석). */ - const [days, setDays] = useState(null); - useEffect(() => setDays(buildDays(new Date())), []); + /** + * ★ 서버 렌더에서는 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); @@ -116,7 +150,7 @@ export function StayBookingDemo() { const [guests, setGuests] = useState(2); const [submitted, setSubmitted] = useState(false); - const selectedDay = days?.find((day) => day.iso === dateIso) ?? null; + 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; @@ -146,7 +180,7 @@ export function StayBookingDemo() { {/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */} - {days === null ? ( + {today === null || cursor === null ? (

날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서 확인해 주세요. @@ -156,7 +190,7 @@ export function StayBookingDemo() { payload={payload} onReset={() => setSubmitted(false)} summary={[ - selectedDay ? `${selectedDay.month}월 ${selectedDay.day}일(${WEEKDAY_LABEL[selectedDay.weekday]})` : null, + selectedDay ? `${cursor.getMonth() + 1}월 ${selectedDay.day}일(${WEEKDAY_LABEL[selectedDay.weekday]})` : null, slot ? `도착 ${slot}` : null, selectedUnit?.name ?? null, `${guests}명`, @@ -166,34 +200,80 @@ export function StayBookingDemo() { /> ) : (

- {/* ── 날짜 ─────────────────────────────────────── */} + {/* ── 날짜 (달력) ──────────────────────────────── */}
-

날짜

-
    - {days.map((day) => { - const active = day.iso === dateIso; - return ( -
  • - -
  • - ); - })} -
+
+

날짜

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

주말 요금이 적용되는 날짜입니다.

+

주말 요금이 적용되는 날짜입니다.

)}
@@ -287,7 +367,7 @@ export function StayBookingDemo() {
{selectedDay - ? `${selectedDay.month}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명` + ? `${cursor.getMonth() + 1}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명` : '날짜를 골라 주세요'} {price != null && (