[fix] solution/frontend: 빌더 캔버스의 예약 섹션을 발행본과 맞춘다 — 실시간 예약 문구 제거

편집 화면이 "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약하실 수 있습니다" 를
그리고 있었다. 우리는 실시간 재고를 갖지 않고(PRODUCT.md 6절), 발행본은 날짜·시간을 고르는
화면이다 — 사장님이 에디터에서 본 것과 발행된 사이트가 서로 다른 물건이었다.
에디터가 보여주는 것이 곧 발행될 것이어야 한다.

- booking/BookingCard: 발행본 구성(날짜 칩·도착 시간·인원·예약 요청·전화 창구)의
  미리보기로 교체. 캔버스 클릭은 섹션 선택이라 상태를 두지 않고 첫 칸 선택 모습으로 고정.
  시간 칸은 발행본과 같은 규칙 — 체크인 fact 가 있을 때만 그린다
- booking/BookingBanner: "실시간 캘린더에서 남은 날짜" → "날짜와 시간을 고르고 예약 창구로"
- rooms/RoomCard: "실시간 예약 신청" → "예약 안내 보기"
- hero/HeroEditorial: "실시간 예약" → "예약 안내"
- api/generated linkChannel: NAVER_BOOKING=7 추가. ★ npm run orval 을 그대로 돌리면
  141파일 6,400줄이 바뀌는데 전부 따옴표·줄바꿈 포매팅 드리프트다 — 생성물을 되돌리고
  스펙 변경분 한 줄만 남겼다

tsc·eslint 통과(frontend·site), vitest 51 passed. solution-site 재빌드 후 번들에서
옛 문구 0건 확인.
This commit is contained in:
hbyang 2026-09-09 10:37:17 +09:00
parent 0b33f035ef
commit c43c4f3620
7 changed files with 164 additions and 23 deletions

View File

@ -36,6 +36,20 @@
**연동을 붙일 자리** — `ConfirmPanel` 한 곳이다. 실시간 재고·접수가 생기면 그 함수만 바뀐다.
**빌더 캔버스도 같이 맞췄다** — 사장님 편집 화면은 여전히 "네이버 실시간 온라인 예약 /
캘린더에서 바로 확정 예약" 을 그리고 있었다. 우리는 실시간 예약을 하지 않는데다,
**에디터에서 본 것과 발행된 사이트가 서로 다른 물건**이었다.
- `booking/BookingCard`: 발행본 구성(날짜 칩 · 도착 시간 · 인원 · 예약 요청 · 전화 창구)의
미리보기로 갈아엎었다. 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라 상태를 두지 않고
첫 칸이 골라진 모습으로 고정한다. 시간 칸은 발행본과 같은 규칙으로 **체크인 fact 가 있을
때만** 그린다
- `booking/BookingBanner` "실시간 캘린더" → "날짜와 시간을 고르고 예약 창구로 이어집니다",
`rooms/RoomCard` "실시간 예약 신청" → "예약 안내 보기", `hero/HeroEditorial` "실시간 예약"
→ "예약 안내"
- `LinkChannel.NAVER_BOOKING` 을 orval 생성물에 반영. ★ `npm run orval` 을 그대로 돌리면
**141파일 6,400줄**이 바뀐다 — 전부 따옴표·줄바꿈 포매팅 드리프트고 스펙 변경은 enum
한 줄뿐이다. 그래서 생성물을 되돌리고 그 한 줄만 남겼다(실측 2026-09-09)
**검증** — `tsc·eslint` 통과, `vitest` 51 passed(신규 4건: 날짜가 HTML 에 안 박히는지 ·
JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후
`/s/<slug>` 에서 데모 껍데기와 안내 문구 확인.

View File

@ -19,5 +19,6 @@ export const LinkChannel = {
INSTAGRAM: 4,
OFFICIAL_SITE: 5,
BLOG: 6,
NAVER_BOOKING: 7,
ETC: 99,
} as const;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PlaceSearchItemNaverPlaceUrl = string | null;

View File

@ -1,6 +1,10 @@
/**
* · .
* ( ).
*
* "실시간 캘린더" (2026-09-09).
* (PRODUCT.md 6). ·
* . .
*/
import {Calendar} from 'lucide-react';
import {CtaLink, SectionFrame} from '../../primitives';
@ -19,14 +23,14 @@ export function BookingBanner(props: SectionRenderProps) {
{section.name}
</h3>
<p className="text-xs opacity-90 sm:text-sm">
{section.body || section.description || `${storeName} · 실시간 캘린더에서 남은 날짜를 확인할 수 있습니다`}
{section.body || section.description || `${storeName} · 날짜와 시간을 고르고 예약 창구로 이어집니다`}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<CtaLink href={bookingHref(industryId, storeName)} variant="pill">
<Calendar className="size-3.5" />
<span> </span>
<span> · </span>
</CtaLink>
</div>
</div>

View File

@ -1,34 +1,146 @@
/**
* · . .
* · ** .**
*
* (2026-09-09)
* "네이버 실시간 온라인 예약 / 캘린더에서 바로 확정 예약" .
* (PRODUCT.md 6 ).
* · ,
* .
* ** ** .
*
* ** .** "이 섹션을 고른다"
* . ,
* (`solution/site` StayBookingDemo) .
*
* ** fact ** .
* 16:00 14:00 .
*/
import {Calendar} from 'lucide-react';
import {CtaLink, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import {CalendarDays, Clock, Minus, Phone, Plus} from 'lucide-react';
import type {InfoField} from '@o2o/shared';
import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {bookingHref} from '../common';
const WEEKDAY_LABEL = ['일', '월', '화', '수', '목', '금', '토'] as const;
/** 7.
* ( HTML StayBookingDemo ). */
function nextDays(count: number) {
const today = new Date();
return Array.from({length: count}, (_, index) => {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() + index);
return {day: date.getDate(), weekday: WEEKDAY_LABEL[date.getDay()]};
});
}
/** 확인된 값만 읽는다 — 캔버스도 발행본과 같은 규칙이다. */
function verified(fields: InfoField[], id: string): string | undefined {
const field = fields.find((f) => f.id === id);
if (!field || field.requiresVerification || !field.value?.trim()) return undefined;
return field.value.trim();
}
function arrivalSlots(checkIn?: string): string[] {
const match = /(\d{1,2})\s*:\s*(\d{2})/.exec(checkIn ?? '');
if (!match) return [];
const start = Number(match[1]);
if (!Number.isFinite(start)) return [];
return Array.from({length: 4}, (_, i) => start + i)
.filter((hour) => hour <= 23)
.map((hour) => `${String(hour).padStart(2, '0')}:${match[2]}`);
}
export function BookingCard(props: SectionRenderProps) {
const {section, isSelected, onSelect, industryId, storeName, template} = props;
const {section, isSelected, onSelect, template, infoFields} = props;
const days = nextDays(7);
const checkIn = verified(infoFields, 'check_in_time');
const slots = arrivalSlots(checkIn);
const phone = verified(infoFields, 'phone');
const accent = template.colors.primary;
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
<SectionBody>
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
<div className="mx-auto max-w-lg space-y-3 rounded-xl border border-stone-200/80 bg-white p-6 text-center shadow-2xs">
<h4 className="text-sm font-bold text-stone-900" style={{color: template.colors.text}}>
</h4>
<p className="text-xs text-stone-500">
{section.body || '원하시는 날짜와 객실을 선택하여 캘린더에서 바로 확정 예약하실 수 있습니다.'}
<div className="mx-auto max-w-lg overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-2xs">
<p className="flex items-center gap-2 border-b border-stone-200/80 px-4 py-3 text-xs font-bold text-stone-900">
<CalendarDays className="size-3.5 text-stone-400" />
<span> · </span>
</p>
<CtaLink
href={bookingHref(industryId, storeName)}
variant="solid"
colors={template.colors}
<div className="space-y-4 p-4">
<div>
<p className="mb-1.5 text-[11px] font-semibold text-stone-400"></p>
<ul className="flex gap-1.5 overflow-hidden">
{days.map((day, index) => (
<li
key={day.day}
className="flex w-11 shrink-0 flex-col items-center gap-0.5 rounded-lg border px-2 py-1.5 text-xs"
style={
index === 0
? {borderColor: accent, backgroundColor: accent, color: '#fff'}
: {borderColor: 'rgb(231 229 228)', backgroundColor: 'rgb(250 250 249)'}
}
>
<Calendar className="size-3.5" />
<span> </span>
</CtaLink>
<span className="text-[10px] opacity-70">{day.weekday}</span>
<span className="font-bold">{day.day}</span>
</li>
))}
</ul>
</div>
{slots.length > 0 && (
<div>
<p className="mb-1.5 flex items-center gap-1 text-[11px] font-semibold text-stone-400">
<Clock className="size-3" />
<span> ( {checkIn} )</span>
</p>
<ul className="flex flex-wrap gap-1.5">
{slots.map((time, index) => (
<li
key={time}
className="rounded-md border px-2.5 py-1 text-xs"
style={
index === 0
? {borderColor: accent, backgroundColor: accent, color: '#fff'}
: {borderColor: 'rgb(231 229 228)', backgroundColor: 'rgb(250 250 249)'}
}
>
{time}
</li>
))}
</ul>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold text-stone-400"></p>
<div className="flex items-center gap-2 text-stone-500">
<span className="flex size-6 items-center justify-center rounded-md border border-stone-200">
<Minus className="size-3" />
</span>
<span className="w-9 text-center text-sm font-bold text-stone-900">2</span>
<span className="flex size-6 items-center justify-center rounded-md border border-stone-200">
<Plus className="size-3" />
</span>
</div>
</div>
<div
className="rounded-lg px-4 py-2.5 text-center text-xs font-bold text-white"
style={{backgroundColor: accent}}
>
</div>
{/* ★ 실제 예약이 되는 창구. 발행본과 같은 순서다 — 데모 다음에 진짜 창구가 온다. */}
{phone && (
<p className="flex items-center justify-center gap-1.5 text-[11px] text-stone-500">
<Phone className="size-3" />
<span> {phone}</span>
</p>
)}
</div>
</div>
</SectionBody>
</SectionFrame>

View File

@ -55,7 +55,7 @@ export function HeroEditorial(props: SectionRenderProps) {
colors={template.colors}
>
<Calendar className="size-3.5" />
<span> </span>
<span> </span>
</CtaLink>
</div>
</div>

View File

@ -129,7 +129,9 @@ export function RoomCard({
const action = (
<div className="p-4 pt-0 sm:p-5 sm:pt-0">
<CtaLink href={bookingUrl} variant="solid" size="sm" colors={colors} block>
<span> </span>
{/* " " (2026-09-09 ).
. */}
<span> </span>
<ExternalLink className="size-3.5" />
</CtaLink>
</div>