[fix] solution/site: 예약 목업의 날짜를 달력으로 — 14칸 스트립을 걷어낸다
날짜를 한 줄로 펼쳐 놓으니 예약 화면으로 안 읽혔다. 두 주만 보이는 것도 문제였다 —
손님이 다음 달을 잡으려면 방법이 없었다.
- 월 단위 그리드로 바꿨다. 요일 머리(일~토)를 두고 1일 앞을 빈 칸으로 채워 **열을 맞춘다** —
달력은 열이 맞아야 달력이고, 어긋나면 그냥 숫자 목록이다
- 이전·다음 달. 오늘이 든 달이 하한이고 앞으로 두 달까지다(MONTH_SPAN).
★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤까지 열어 두면 손님은 그 날짜가
열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다
- 토·일은 요일 머리와 함께 구분한다. 장식이 아니라 주말 요금이 붙는 날이라는 정보다
- **고를 수 없는 날은 지난 날짜뿐이다.** '마감'·'잔여' 는 여전히 만들지 않는다 —
우리는 그 값을 모르고, 지어내면 손님이 그걸 보고 다른 날을 고른다
★ 서버 렌더 게이트는 그대로다. '오늘' 을 브라우저에서 정하므로 정적 HTML 에는 달력이
굽히지 않는다 — 한 달 뒤 크롤러가 지난 날짜를 예약 가능일로 읽는 일이 없다.
site tsc·eslint 통과 · vitest 51 passed(SSR 이 날짜를 굽지 않는지 보는 기존 검사 포함).
This commit is contained in:
parent
a7fcc14e7d
commit
868127a69a
@ -1,5 +1,5 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react';
|
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 {factText, sanitizeUnits, selectPublishable, type SitePayload} from '@o2o/shared';
|
||||||
import {useSite} from '@site/lib/site-context';
|
import {useSite} from '@site/lib/site-context';
|
||||||
import {unitBaseRate} from '@site/seo/jsonld';
|
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 WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고를 수 있는 앞선 달 수. 오늘이 든 달부터 이만큼 앞으로 넘길 수 있다.
|
||||||
|
*
|
||||||
|
* ★ 상한을 두는 이유: 우리는 빈 방을 모른다. 반년 뒤를 고르게 두면 손님은 그 날짜가
|
||||||
|
* 열려 있다고 읽는다 — 모르는 것을 넓게 열어 두는 쪽이 더 나쁜 거짓이다.
|
||||||
|
*/
|
||||||
|
const MONTH_SPAN = 2;
|
||||||
|
|
||||||
interface DayCell {
|
interface DayCell {
|
||||||
iso: string;
|
iso: string;
|
||||||
month: number;
|
|
||||||
day: number;
|
day: number;
|
||||||
weekday: number;
|
weekday: number;
|
||||||
/** 토·일은 주말 요금이 붙는 날이다. 요금 계산의 근거가 화면에도 보여야 한다. */
|
/** 토·일은 주말 요금이 붙는 날이다. 요금 계산의 근거가 화면에도 보여야 한다. */
|
||||||
isWeekend: boolean;
|
isWeekend: boolean;
|
||||||
|
/** 지난 날짜. **고를 수 없는 유일한 사유**다 — '마감' 같은 표시는 만들지 않는다(머리주석). */
|
||||||
|
past: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDays(from: Date): DayCell[] {
|
function isoOf(date: Date): string {
|
||||||
return Array.from({length: DAY_COUNT}, (_, index) => {
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
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')}`,
|
* 한 달치 칸. 1일이 무슨 요일인지에 따라 앞에 빈 칸을 채워 **요일 열을 맞춘다** —
|
||||||
month: date.getMonth() + 1,
|
* 달력은 열이 맞아야 달력이고, 어긋나면 그냥 숫자 목록이다.
|
||||||
day: date.getDate(),
|
*/
|
||||||
weekday,
|
function buildMonth(year: number, month: number, today: Date): (DayCell | null)[] {
|
||||||
isWeekend: weekday === 0 || weekday === 6,
|
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]);
|
const slots = useMemo(() => buildArrivalSlots(checkIn), [checkIn]);
|
||||||
|
|
||||||
/** ★ 서버 렌더에서는 false — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석). */
|
/**
|
||||||
const [days, setDays] = useState<DayCell[] | null>(null);
|
* ★ 서버 렌더에서는 null — 날짜를 HTML 에 굽지 않기 위한 게이트(머리주석).
|
||||||
useEffect(() => setDays(buildDays(new Date())), []);
|
* '오늘' 을 브라우저에서 정하므로 이 값이 곧 달력의 기준이다.
|
||||||
|
*/
|
||||||
|
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 [dateIso, setDateIso] = useState<string | null>(null);
|
||||||
const [slot, setSlot] = useState<string | null>(null);
|
const [slot, setSlot] = useState<string | null>(null);
|
||||||
@ -116,7 +150,7 @@ export function StayBookingDemo() {
|
|||||||
const [guests, setGuests] = useState(2);
|
const [guests, setGuests] = useState(2);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
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 selectedUnit = units.find((unit) => unit.unitId === unitId) ?? null;
|
||||||
const maxGuests = selectedUnit?.maxCapacity ?? 8;
|
const maxGuests = selectedUnit?.maxCapacity ?? 8;
|
||||||
|
|
||||||
@ -146,7 +180,7 @@ export function StayBookingDemo() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
|
{/* 서버 렌더 · 자바스크립트 꺼짐: 달력 대신 사실만 내보낸다(머리주석). */}
|
||||||
{days === null ? (
|
{today === null || cursor === null ? (
|
||||||
<p className="px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5">
|
<p className="px-4 py-6 text-xs leading-relaxed opacity-60 sm:px-5">
|
||||||
날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서
|
날짜 선택은 브라우저에서 열립니다. 실제 예약 가능 여부와 결제는 아래 예약 창구에서
|
||||||
확인해 주세요.
|
확인해 주세요.
|
||||||
@ -156,7 +190,7 @@ export function StayBookingDemo() {
|
|||||||
payload={payload}
|
payload={payload}
|
||||||
onReset={() => setSubmitted(false)}
|
onReset={() => setSubmitted(false)}
|
||||||
summary={[
|
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,
|
slot ? `도착 ${slot}` : null,
|
||||||
selectedUnit?.name ?? null,
|
selectedUnit?.name ?? null,
|
||||||
`${guests}명`,
|
`${guests}명`,
|
||||||
@ -166,34 +200,80 @@ export function StayBookingDemo() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5 p-4 sm:p-5">
|
<div className="space-y-5 p-4 sm:p-5">
|
||||||
{/* ── 날짜 ─────────────────────────────────────── */}
|
{/* ── 날짜 (달력) ──────────────────────────────── */}
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-[11px] font-semibold opacity-55">날짜</p>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<ul className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1">
|
<p className="text-[11px] font-semibold opacity-55">날짜</p>
|
||||||
{days.map((day) => {
|
<div className="flex items-center gap-1">
|
||||||
const active = day.iso === dateIso;
|
<button
|
||||||
return (
|
type="button"
|
||||||
<li key={day.iso}>
|
onClick={() => setMonthOffset((n) => Math.max(0, n - 1))}
|
||||||
<button
|
disabled={monthOffset === 0}
|
||||||
type="button"
|
aria-label="이전 달"
|
||||||
onClick={() => setDateIso(day.iso)}
|
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"
|
||||||
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"
|
<ChevronLeft className="size-3.5" />
|
||||||
style={{
|
</button>
|
||||||
borderColor: active ? 'var(--color-brand)' : 'rgba(0,0,0,0.10)',
|
<span className="w-24 text-center text-xs font-bold">
|
||||||
backgroundColor: active ? 'var(--color-brand)' : 'var(--color-surface-alt)',
|
{cursor.getFullYear()}년 {cursor.getMonth() + 1}월
|
||||||
color: active ? '#fff' : undefined,
|
</span>
|
||||||
}}
|
<button
|
||||||
>
|
type="button"
|
||||||
<span className="text-[10px] opacity-70">{WEEKDAY_LABEL[day.weekday]}</span>
|
onClick={() => setMonthOffset((n) => Math.min(MONTH_SPAN, n + 1))}
|
||||||
<span className="font-bold">{day.day}</span>
|
disabled={monthOffset >= MONTH_SPAN}
|
||||||
</button>
|
aria-label="다음 달"
|
||||||
</li>
|
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" />
|
||||||
</ul>
|
</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-[10px] 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-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 && (
|
{selectedDay?.isWeekend && (
|
||||||
<p className="mt-1.5 text-[11px] opacity-55">주말 요금이 적용되는 날짜입니다.</p>
|
<p className="mt-2 text-[11px] opacity-55">주말 요금이 적용되는 날짜입니다.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -287,7 +367,7 @@ export function StayBookingDemo() {
|
|||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<span className="text-xs opacity-60">
|
<span className="text-xs opacity-60">
|
||||||
{selectedDay
|
{selectedDay
|
||||||
? `${selectedDay.month}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명`
|
? `${cursor.getMonth() + 1}월 ${selectedDay.day}일 · ${selectedUnit?.name ?? ''} · ${guests}명`
|
||||||
: '날짜를 골라 주세요'}
|
: '날짜를 골라 주세요'}
|
||||||
</span>
|
</span>
|
||||||
{price != null && (
|
{price != null && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user