[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` 한 곳이다. 실시간 재고·접수가 생기면 그 함수만 바뀐다.
|
**연동을 붙일 자리** — `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 에 안 박히는지 ·
|
**검증** — `tsc·eslint` 통과, `vitest` 51 passed(신규 4건: 날짜가 HTML 에 안 박히는지 ·
|
||||||
JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후
|
JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후
|
||||||
`/s/<slug>` 에서 데모 껍데기와 안내 문구 확인.
|
`/s/<slug>` 에서 데모 껍데기와 안내 문구 확인.
|
||||||
|
|||||||
@ -19,5 +19,6 @@ export const LinkChannel = {
|
|||||||
INSTAGRAM: 4,
|
INSTAGRAM: 4,
|
||||||
OFFICIAL_SITE: 5,
|
OFFICIAL_SITE: 5,
|
||||||
BLOG: 6,
|
BLOG: 6,
|
||||||
|
NAVER_BOOKING: 7,
|
||||||
ETC: 99,
|
ETC: 99,
|
||||||
} as const;
|
} 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 {Calendar} from 'lucide-react';
|
||||||
import {CtaLink, SectionFrame} from '../../primitives';
|
import {CtaLink, SectionFrame} from '../../primitives';
|
||||||
@ -19,14 +23,14 @@ export function BookingBanner(props: SectionRenderProps) {
|
|||||||
{section.name}
|
{section.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs opacity-90 sm:text-sm">
|
<p className="text-xs opacity-90 sm:text-sm">
|
||||||
{section.body || section.description || `${storeName} · 실시간 캘린더에서 남은 날짜를 확인할 수 있습니다`}
|
{section.body || section.description || `${storeName} · 날짜와 시간을 고르고 예약 창구로 이어집니다`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<CtaLink href={bookingHref(industryId, storeName)} variant="pill">
|
<CtaLink href={bookingHref(industryId, storeName)} variant="pill">
|
||||||
<Calendar className="size-3.5" />
|
<Calendar className="size-3.5" />
|
||||||
<span>예약 캘린더</span>
|
<span>날짜 · 시간 고르기</span>
|
||||||
</CtaLink>
|
</CtaLink>
|
||||||
</div>
|
</div>
|
||||||
</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 {CalendarDays, Clock, Minus, Phone, Plus} from 'lucide-react';
|
||||||
import {CtaLink, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
|
import type {InfoField} from '@o2o/shared';
|
||||||
|
import {SectionBody, SectionFrame, SectionHeading} from '../../primitives';
|
||||||
import type {SectionRenderProps} from '../../types';
|
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) {
|
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 (
|
return (
|
||||||
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
|
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="white">
|
||||||
<SectionBody>
|
<SectionBody>
|
||||||
<SectionHeading title={section.name} subtitle={section.description} colors={template.colors} />
|
<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">
|
<div className="mx-auto max-w-lg overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-2xs">
|
||||||
<h4 className="text-sm font-bold text-stone-900" style={{color: template.colors.text}}>
|
<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" />
|
||||||
</h4>
|
<span>날짜 · 시간 선택</span>
|
||||||
<p className="text-xs text-stone-500">
|
|
||||||
{section.body || '원하시는 날짜와 객실을 선택하여 캘린더에서 바로 확정 예약하실 수 있습니다.'}
|
|
||||||
</p>
|
</p>
|
||||||
<CtaLink
|
|
||||||
href={bookingHref(industryId, storeName)}
|
<div className="space-y-4 p-4">
|
||||||
variant="solid"
|
<div>
|
||||||
colors={template.colors}
|
<p className="mb-1.5 text-[11px] font-semibold text-stone-400">날짜</p>
|
||||||
>
|
<ul className="flex gap-1.5 overflow-hidden">
|
||||||
<Calendar className="size-3.5" />
|
{days.map((day, index) => (
|
||||||
<span>네이버 예약 캘린더 열기</span>
|
<li
|
||||||
</CtaLink>
|
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)'}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</SectionBody>
|
</SectionBody>
|
||||||
</SectionFrame>
|
</SectionFrame>
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export function HeroEditorial(props: SectionRenderProps) {
|
|||||||
colors={template.colors}
|
colors={template.colors}
|
||||||
>
|
>
|
||||||
<Calendar className="size-3.5" />
|
<Calendar className="size-3.5" />
|
||||||
<span>실시간 예약</span>
|
<span>예약 안내</span>
|
||||||
</CtaLink>
|
</CtaLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -129,7 +129,9 @@ export function RoomCard({
|
|||||||
const action = (
|
const action = (
|
||||||
<div className="p-4 pt-0 sm:p-5 sm:pt-0">
|
<div className="p-4 pt-0 sm:p-5 sm:pt-0">
|
||||||
<CtaLink href={bookingUrl} variant="solid" size="sm" colors={colors} block>
|
<CtaLink href={bookingUrl} variant="solid" size="sm" colors={colors} block>
|
||||||
<span>실시간 예약 신청</span>
|
{/* ★ "실시간 예약 신청" 이었다(2026-09-09 수정). 실시간 재고를 갖지 않으므로
|
||||||
|
누르면 예약이 확정되는 것처럼 읽히면 안 된다 — 발행본과 같은 말로 맞춘다. */}
|
||||||
|
<span>예약 안내 보기</span>
|
||||||
<ExternalLink className="size-3.5" />
|
<ExternalLink className="size-3.5" />
|
||||||
</CtaLink>
|
</CtaLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user