[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:
parent
0b33f035ef
commit
c43c4f3620
@ -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>` 에서 데모 껍데기와 안내 문구 확인.
|
||||
|
||||
@ -19,5 +19,6 @@ export const LinkChannel = {
|
||||
INSTAGRAM: 4,
|
||||
OFFICIAL_SITE: 5,
|
||||
BLOG: 6,
|
||||
NAVER_BOOKING: 7,
|
||||
ETC: 99,
|
||||
} as const;
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -55,7 +55,7 @@ export function HeroEditorial(props: SectionRenderProps) {
|
||||
colors={template.colors}
|
||||
>
|
||||
<Calendar className="size-3.5" />
|
||||
<span>실시간 예약</span>
|
||||
<span>예약 안내</span>
|
||||
</CtaLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user