에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):
발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
(gallery↔photos · location↔map · guide↔local)
겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)
소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.
**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.
**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.
**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).
**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.
**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.
곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**
검증(Playwright, 같은 사업장·1024px):
섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
15개 전부 높이·글자 수·제목이 정확히 같다
편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
`/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
645 lines
29 KiB
TypeScript
645 lines
29 KiB
TypeScript
import {
|
|
LinkChannel,
|
|
PlaceCategory,
|
|
displayFactLabel,
|
|
factText,
|
|
parseSectionData,
|
|
sanitizeUnits,
|
|
selectPublishable,
|
|
selectPublishableFaqs,
|
|
type ChannelLink,
|
|
type FactEntry,
|
|
type MediaItem,
|
|
type SitePayload,
|
|
type UnitInfo,
|
|
type WeatherMood,
|
|
} from '@o2o/shared';
|
|
import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@site/seo/jsonld';
|
|
|
|
/**
|
|
* payload → 화면이 바로 쓰는 모양.
|
|
*
|
|
* 컴포넌트가 fact 배열을 직접 뒤지지 않게 한 층 둔다. 여기 한 곳에서만
|
|
* selectPublishable() 을 통과시키므로, 컴포넌트가 실수로 미검증 값을 그릴 수가 없다.
|
|
*/
|
|
|
|
export interface InfoRow {
|
|
label: string;
|
|
value: string;
|
|
/** 부가 설명. 출처가 아니라 사장님이 붙인 보충 문구. */
|
|
note?: string;
|
|
}
|
|
|
|
/** 사업장 단위 이용 정보 표. 확인된 것만 들어간다. */
|
|
export function essentialRows(payload: SitePayload): InfoRow[] {
|
|
return selectPublishable(payload.facts)
|
|
.filter((fact) => fact.scope === 'place')
|
|
.map((fact) => ({
|
|
label: displayFactLabel(fact),
|
|
value: displayValue(fact, payload.facts),
|
|
}))
|
|
.filter((row) => row.value !== '');
|
|
}
|
|
|
|
function displayValue(fact: FactEntry, all: FactEntry[]): string {
|
|
const text = factText(all, fact.key);
|
|
if (!text) return '';
|
|
if (fact.type !== 'bool') return text;
|
|
// '주방 여부' 는 가능/불가가 아니라 있음/없음이다.
|
|
if (/여부$/.test(fact.label)) return text === 'true' ? '있음' : text === 'false' ? '없음' : text;
|
|
if (text === 'true') return '가능';
|
|
if (text === 'false') return '불가';
|
|
return text;
|
|
}
|
|
|
|
export interface UnitView {
|
|
unitId: string;
|
|
slug: string;
|
|
name: string;
|
|
intro?: string;
|
|
/** 스펙 칩 — 인원 · 침대 · 면적처럼 한눈에 보는 값. */
|
|
chips: {label: string; value: string}[];
|
|
rows: InfoRow[];
|
|
images: MediaItem[];
|
|
priceText?: string;
|
|
/** 주중 · 주말 · 성수기 요금. 숙박이 아니면 빈 배열이다. */
|
|
prices: {label: string; value: string}[];
|
|
href: string;
|
|
}
|
|
|
|
/** 객실 정보에서 내지 않는 fact. 요금은 요금 섹션 한 곳에서만 말한다. */
|
|
const UNIT_PRICE_FACT_KEYS = new Set(['weekday_price', 'weekend_price', 'peak_price', 'price_range']);
|
|
|
|
export function unitViews(payload: SitePayload): UnitView[] {
|
|
const spec = UNIT_SPEC[payload.place.category];
|
|
const mediaById = new Map(payload.media.map((m) => [m.mediaId, m]));
|
|
|
|
return sanitizeUnits(payload.units).map((unit) => {
|
|
const intro = factText(unit.facts, 'room_intro') ?? factText(unit.facts, 'description');
|
|
const chipKeys =
|
|
payload.place.category === PlaceCategory.LODGING
|
|
? // ★ 기준 인원을 빼 놓았었다. 국내 숙박은 '기준 2인 / 최대 4인' 이 한 쌍이고,
|
|
// 최대만 적으면 4인이 기본요금인 줄 알고 예약했다가 추가요금에서 실랑이가 난다.
|
|
['standard_capacity', 'max_capacity', 'bed_type', 'room_size']
|
|
: ['price', 'volume', 'origin'];
|
|
|
|
return {
|
|
unitId: unit.unitId,
|
|
slug: unit.slug,
|
|
name: unit.name,
|
|
intro,
|
|
chips: chipKeys
|
|
.map((key) => {
|
|
const value = factText(unit.facts, key);
|
|
const label = unit.facts.find((f) => f.key === key)?.label ?? key;
|
|
return value ? {label, value} : null;
|
|
})
|
|
.filter((chip): chip is {label: string; value: string} => chip !== null),
|
|
/*
|
|
* ★ 요금 항목은 '자세히' 표에서도 뺀다 (2026-09-07, 사장님 지시: "객실 정보에서 가격 지우기")
|
|
* 카드 위의 요금 표만 걷어냈더니 접힌 표 안에 '주중 요금 198,000원' 이 그대로 남아
|
|
* 지시가 반만 먹었다. 객실 정보에서 값은 **한 군데도** 나오지 않는다.
|
|
* `prices`·`priceText` 는 그대로 둔다 — 요금 섹션과 예약 안내가 계속 쓴다.
|
|
*/
|
|
rows: unit.facts
|
|
.filter((fact) => !UNIT_PRICE_FACT_KEYS.has(fact.key))
|
|
.map((fact) => ({label: displayFactLabel(fact), value: displayValue(fact, unit.facts)}))
|
|
.filter((row) => row.value !== ''),
|
|
images: unit.mediaIds
|
|
.map((id) => mediaById.get(id))
|
|
.filter((m): m is MediaItem => Boolean(m?.alt?.trim())),
|
|
priceText: unitPriceText(unit),
|
|
prices: unitPrices(unit),
|
|
href: `/${spec.path}/${unit.slug}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 카드에 얹는 "얼마부터".
|
|
*
|
|
* ★ 숫자를 여기서 고르지 않는다 — `unitBaseRate`(seo/jsonld.ts) 하나가 고른 값을 표기만 한다.
|
|
* 화면과 JSON-LD 가 각자 계산하면 어긋날 수 있고, 어긋나면 절대규칙 3 위반으로 발행이 막힌다.
|
|
*/
|
|
function unitPriceText(unit: UnitInfo): string | undefined {
|
|
/*
|
|
* ★ 예약 채널이 **범위로** 파는 방은 범위 그대로 낸다 (2026-09-04)
|
|
* 머뭄은 네이버에 "198,000 ~ 350,000원" 으로 걸려 있다. 최저가만 "198,000원부터" 로
|
|
* 내면 손님이 그 값으로 알고 들어왔다가 결제 화면에서 두 배를 본다 — 채널이 말하는
|
|
* 것과 우리가 말하는 것이 갈리면 안 된다. 주중/주말/성수기가 실제로 갈리는 가게는
|
|
* 그쪽 세 칸을 쓰고, 범위만 아는 가게는 이 한 줄을 쓴다.
|
|
*/
|
|
const range = factText(unit.facts, 'price_range');
|
|
if (range) return range;
|
|
|
|
const weekday = Number(factText(unit.facts, 'weekday_price')?.replace(/[^0-9]/g, ''));
|
|
const price = Number(factText(unit.facts, 'price')?.replace(/[^0-9]/g, ''));
|
|
const base = Number.isFinite(weekday) && weekday > 0 ? weekday : price;
|
|
if (!Number.isFinite(base) || base <= 0) return undefined;
|
|
return `${base.toLocaleString('ko-KR')}원부터`;
|
|
}
|
|
|
|
/**
|
|
* 요금 세 칸 — 주중 · 주말 · 성수기.
|
|
*
|
|
* ★ 없는 칸을 지우지 않고 '문의' 로 남긴다. 펜션에서 요금은 **비교하는 값**이라,
|
|
* 주중 하나만 적어 두면 손님은 주말이 얼마인지 모른 채 떠난다. 세 칸을 세워 두면
|
|
* 비어 있다는 사실 자체가 "전화로 묻는 값" 이라는 안내가 된다.
|
|
* ★ 주중 요금이 아예 없으면 표를 만들지 않는다 — 음식점 메뉴(`price`)까지 세 칸이 서고
|
|
* 전부 '문의' 가 되면 없느니만 못하다.
|
|
*/
|
|
const UNIT_PRICE_KEYS = [
|
|
['weekday_price', '주중'],
|
|
['weekend_price', '주말'],
|
|
['peak_price', '성수기'],
|
|
] as const;
|
|
|
|
function unitPrices(unit: UnitInfo): {label: string; value: string}[] {
|
|
// 범위만 아는 가게는 세 칸을 세우지 않는다 — 두 칸이 '문의' 인 표보다 범위 한 줄이 정확하다.
|
|
if (factText(unit.facts, 'price_range')) return [];
|
|
if (!factText(unit.facts, 'weekday_price')) return [];
|
|
return UNIT_PRICE_KEYS.map(([key, label]) => ({
|
|
label,
|
|
value: factText(unit.facts, key) ?? '문의',
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 기상청 문구 → 넷 중 하나.
|
|
*
|
|
* ★ 조건 문자열은 출처마다 다르다(맑음 · 구름많음 · 흐림 · 비/눈 · 소나기 …).
|
|
* 화면이 갈라야 하는 건 **문장과 그림** 둘뿐이라 넷으로 줄인다.
|
|
* ★ 순서가 중요하다 — "비/눈" 같은 합성 표기에서 눈을 먼저 본다.
|
|
*/
|
|
export function weatherMood(condition: string | undefined): WeatherMood {
|
|
const text = condition ?? '';
|
|
if (/눈|설/.test(text)) return '눈';
|
|
if (/비|우|소나기/.test(text)) return '비';
|
|
if (/흐림|구름|안개|박무/.test(text)) return '흐림';
|
|
return '맑음';
|
|
}
|
|
|
|
/** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */
|
|
export function galleryImages(payload: SitePayload): MediaItem[] {
|
|
return payload.media.filter((m) => m.alt?.trim() && !m.unitId);
|
|
}
|
|
|
|
export function faqList(payload: SitePayload) {
|
|
return selectPublishableFaqs(payload.faqs);
|
|
}
|
|
|
|
/** 켜져 있는 섹션인지. 순서도 payload 가 정한다. */
|
|
export function enabledSections(payload: SitePayload) {
|
|
return payload.theme.sections.filter((section) => section.enabled);
|
|
}
|
|
|
|
export function isSectionEnabled(payload: SitePayload, id: string): boolean {
|
|
return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false;
|
|
}
|
|
|
|
/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */
|
|
export function sectionBody(payload: SitePayload, id: string): string[] {
|
|
const body = payload.theme.sections.find((section) => section.id === id)?.body;
|
|
return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? [];
|
|
}
|
|
|
|
/**
|
|
* 붙여넣기 아이템의 JSON → 렌더 가능한 항목.
|
|
*
|
|
* ★ 섹션 id 가 곧 아이템 종류다. 붙여넣기 아이템은 [+ 섹션 추가]가 `id = type` 으로 만든다
|
|
* (frontend `canvas/addable.ts`). 그래서 payload 에 type 이 없어도 id 로 종류를 안다.
|
|
* ★ 파서는 shared 한 벌이다 — 빌더와 발행본이 같은 JSON 을 같은 규칙으로 읽어야
|
|
* "빌더에서는 보이는데 발행하면 없다"가 안 생긴다.
|
|
*/
|
|
export function sectionItems<T extends object>(payload: SitePayload, id: string) {
|
|
const section = payload.theme.sections.find((entry) => entry.id === id);
|
|
const parsed = parseSectionData<T>(id, section?.data);
|
|
|
|
/*
|
|
* ★ 사장님이 쓴 것 + 우리가 지역 단위로 만든 것을 **한 배열로 잇는다** (2026-09-09)
|
|
* 가요·인물·연표·엽서·퀴즈는 업장의 사실이 아니라 도시의 사실이라 지역에 한 벌만 두고
|
|
* 같은 지역 사이트가 나눠 쓴다(`payload.local.story`, 서버는 `area_contents`).
|
|
* 그걸 사이트마다 `sections[].data` JSON 으로 복사해 두면 지역 하나 고칠 때 사이트 수만큼
|
|
* 고쳐야 한다 — 그래서 payload 에서 자리를 나누고 **읽는 순간에만** 합친다.
|
|
* ★ 사장님 것이 앞이다. 자기 가게에 대해 자기가 고른 것이 우리가 모아 온 것보다 먼저다.
|
|
* ★ 합치는 자리가 여기 하나인 이유: 다섯 섹션이 모두 이 함수를 거친다. 각자 합치게 하면
|
|
* 한 곳을 빠뜨렸을 때 그 탭만 조용히 사장님 것만 보인다.
|
|
*/
|
|
const shared = (payload.local.story as Record<string, unknown[]> | undefined)?.[id];
|
|
if (!Array.isArray(shared) || shared.length === 0) return parsed;
|
|
|
|
return {...parsed, items: [...parsed.items, ...(shared as T[])]};
|
|
}
|
|
|
|
export function unitSpec(payload: SitePayload) {
|
|
return UNIT_SPEC[payload.place.category];
|
|
}
|
|
|
|
/**
|
|
* 섹션 제목 — 사장님이 [섹션] 패널에서 붙인 이름(`theme.sections[].name`)을 그대로 쓴다.
|
|
*
|
|
* ★ 왜 필요한가
|
|
* 지금까지 발행본 소제목은 컴포넌트에 박힌 문자열이었다. 사장님이 "객실 안내"를
|
|
* "우리 방 소개"로 바꿔도 에디터 캔버스만 바뀌고 발행본은 옛 문구로 나갔다 —
|
|
* 사장님 입장에서는 고친 게 반영이 안 된 것이고, 실제로 반영이 안 된 게 맞다.
|
|
*
|
|
* ★ **모든 섹션이 이걸 쓰는 건 아니다.** 기준은 하나다 — 에디터 캔버스가 그 섹션에서
|
|
* 무엇을 제목으로 쓰는가. 두 화면이 같아야 하므로 발행본은 에디터를 따라간다.
|
|
*
|
|
* 이걸 쓰는 섹션 rules · booking · inquiry · space · exhibition · rooms/menu/programs
|
|
* (에디터: `title={section.name}`)
|
|
* 쓰지 않는 섹션 intro · info · photos · map · local · faq
|
|
* (에디터가 자체 제목을 쓴다: "공간 갤러리", "오시는 길", `${상호} 소개` …)
|
|
*
|
|
* 한때 발행본에서 여섯 섹션 전부에 이걸 걸었다가 소개 제목이 "조이모텔 소개" 에서
|
|
* "소개" 로 짧아졌다. `theme.sections[].name` 은 사장님이 붙인 이름이기도 하지만,
|
|
* 아직 아무것도 안 바꿨으면 서버 기본표(_DEFAULT_THEME)의 짧은 목록 라벨이다 —
|
|
* 그 라벨은 좌측 패널의 navigation 용이지 <h2> 용이 아니다.
|
|
*
|
|
* ★ 폴백을 두는 이유
|
|
* name 이 비어 있는 payload(옛 버전·손으로 만든 fixture)에서 제목 없는 <h2> 가
|
|
* 나가면 문서 구조가 무너지고 검색·낭독기가 섹션을 못 읽는다. 제목은 반드시 채운다.
|
|
*/
|
|
export function sectionName(payload: SitePayload, id: string, fallback: string): string {
|
|
const name = payload.theme.sections.find((section) => section.id === id)?.name?.trim();
|
|
return name ? name : fallback;
|
|
}
|
|
|
|
/**
|
|
* key 목록 순서대로 확인된 place fact 를 표 행으로.
|
|
*
|
|
* ★ 순서가 곧 화면 순서다. 값이 없거나 미검증인 key 는 조용히 빠진다 —
|
|
* "확인 중"이라는 빈 줄을 그리면 손님은 그걸 규정으로 읽는다.
|
|
*/
|
|
function placeRowsByKeys(payload: SitePayload, keys: readonly string[]): InfoRow[] {
|
|
const map = new Map(
|
|
selectPublishable(payload.facts)
|
|
.filter((fact) => fact.scope === 'place')
|
|
.map((fact) => [fact.key, fact] as const),
|
|
);
|
|
|
|
return keys
|
|
.map((key) => map.get(key))
|
|
.filter((fact): fact is FactEntry => fact !== undefined)
|
|
.map((fact) => ({label: displayFactLabel(fact), value: displayValue(fact, payload.facts)}))
|
|
.filter((row) => row.value !== '');
|
|
}
|
|
|
|
/** 확인된 place fact 한 줄. 없으면 undefined — 섹션이 그 칸을 아예 안 그린다. */
|
|
export function placeRow(payload: SitePayload, key: string): InfoRow | undefined {
|
|
return placeRowsByKeys(payload, [key])[0];
|
|
}
|
|
|
|
/**
|
|
* 이용 규정으로 읽히는 fact key.
|
|
*
|
|
* ★ 관리자 캔버스의 `builder/canvas/variants/common.ts` RULE_FIELD_IDS 와 같은 목록이다.
|
|
* 에디터에서 규정으로 보인 항목이 발행본에서 다른 항목이 되면 사장님은 어느 쪽을
|
|
* 믿어야 할지 모른다. 목록이 바뀌면 양쪽을 같이 고친다.
|
|
* ★ 여기 없는 규정은 만들어 내지 않는다. 업종 스키마(lodging.json)에 있는 key 만 적는다.
|
|
*/
|
|
export const RULE_FACT_KEYS = [
|
|
'check_in_time',
|
|
'check_out_time',
|
|
'cancel_policy',
|
|
'cooking_allowed',
|
|
'pet_allowed',
|
|
'smoking',
|
|
'extra_person_fee',
|
|
] as const;
|
|
|
|
/** 이용 규정 줄. 확인된 fact 에서만 만든다 — 없으면 빈 목록이고 섹션 자체가 안 나간다. */
|
|
export function ruleRows(payload: SitePayload): InfoRow[] {
|
|
return placeRowsByKeys(payload, RULE_FACT_KEYS);
|
|
}
|
|
|
|
/**
|
|
* 예약 안내에 실을 fact.
|
|
*
|
|
* 숙박에는 없고(예약은 채널이 받는다) 음식점·피부과·성형외과 스키마에만 있는 key 다.
|
|
* 값이 없는 업종에서는 그냥 빠진다.
|
|
*/
|
|
const BOOKING_FACT_KEYS = ['reservation_required', 'reservation_channel'] as const;
|
|
|
|
export function bookingRows(payload: SitePayload): InfoRow[] {
|
|
return placeRowsByKeys(payload, BOOKING_FACT_KEYS);
|
|
}
|
|
|
|
/**
|
|
* 공간 안내에 실을 fact — "자리가 어떻게 생겼나"에 답하는 것만.
|
|
*
|
|
* ★ 주차·와이파이 같은 편의시설은 넣지 않는다. 그건 이용 정보(info) 표가 이미 낸다.
|
|
*/
|
|
const SPACE_FACT_KEYS = [
|
|
'seat_count',
|
|
'terrace',
|
|
'room_available',
|
|
'group_seat_max',
|
|
'power_outlet',
|
|
'study_allowed',
|
|
'wheelchair_accessible',
|
|
] as const;
|
|
|
|
export function spaceRows(payload: SitePayload): InfoRow[] {
|
|
return placeRowsByKeys(payload, SPACE_FACT_KEYS);
|
|
}
|
|
|
|
/**
|
|
* 안내에 실을 fact — 피부과·성형외과 스키마(clinic.json)의 안내 관련 key.
|
|
*
|
|
* ★ 준비물·안전 유의사항은 뺐다. 그건 "관람 안내"가 아니라 체험 전 주의사항이고,
|
|
* 이용 정보(info) 표에 이미 나간다.
|
|
*/
|
|
const EXHIBITION_FACT_KEYS = [
|
|
'operating_hours',
|
|
'session_times',
|
|
'closed_days',
|
|
'age_limit',
|
|
'guide_language',
|
|
] as const;
|
|
|
|
export function exhibitionRows(payload: SitePayload): InfoRow[] {
|
|
return placeRowsByKeys(payload, EXHIBITION_FACT_KEYS);
|
|
}
|
|
|
|
/**
|
|
* 첫 화면에 세우는 핵심 값 — 손님이 예약을 결정하기 전에 제일 먼저 묻는 것.
|
|
*
|
|
* ★ 업종마다 묻는 게 다르다. 숙박은 체크인/아웃, 카페·음식점은 영업시간, 병원은 진료시간이다.
|
|
* 그래서 key 목록을 업종별로 두지 않고 **우선순위 한 줄**로 두고 앞에서부터 있는 것만 집는다 —
|
|
* 없는 업종에서는 그냥 다음 값이 올라온다.
|
|
* ★ 최대 넷. 다섯 개부터는 훑는 값이 아니라 표가 되고, 그건 아래 이용 정보가 이미 한다.
|
|
*/
|
|
const HERO_FACT_KEYS = [
|
|
'check_in_time',
|
|
'check_out_time',
|
|
'operating_hours',
|
|
'closed_days',
|
|
// ★ 'parking_available' 이라고 적혀 있었다 — 스키마의 key 는 'parking' 이라(lodging.json)
|
|
// 주차가 첫 화면 띠에 한 번도 뜬 적이 없다. 숙박에서 주차는 체크인 다음으로 묻는 값이다.
|
|
'parking',
|
|
'reservation_required',
|
|
] as const;
|
|
|
|
export function heroFacts(payload: SitePayload): InfoRow[] {
|
|
return placeRowsByKeys(payload, HERO_FACT_KEYS).slice(0, 4);
|
|
}
|
|
|
|
/**
|
|
* 가장 싼 값.
|
|
*
|
|
* ★ 숫자를 다시 계산하지 않고 **사장님이 쓴 문자열을 그대로** 고른다. 단위·표기가
|
|
* 가게마다 다르고("15만원~", "150,000원/박"), 우리가 파싱해 다시 쓰면 없던 값이 생긴다.
|
|
* 비교는 문자열에서 숫자만 뽑아 하고, 화면에 나가는 건 원문이다.
|
|
*/
|
|
export function lowestPrice(payload: SitePayload): string | undefined {
|
|
const priced = unitViews(payload)
|
|
.map((unit) => unit.priceText)
|
|
.filter((text): text is string => Boolean(text));
|
|
if (priced.length === 0) return undefined;
|
|
const num = (text: string) => Number(text.replace(/[^0-9]/g, '')) || Number.MAX_SAFE_INTEGER;
|
|
return priced.reduce((min, text) => (num(text) < num(min) ? text : min));
|
|
}
|
|
|
|
/** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */
|
|
export const CHANNEL_LABEL: Record<number, string> = {
|
|
[LinkChannel.NAVER_BOOKING]: '네이버 예약',
|
|
[LinkChannel.YANOLJA]: '야놀자',
|
|
[LinkChannel.GOODCHOICE]: '여기어때',
|
|
[LinkChannel.NAVER_PLACE]: '네이버 플레이스',
|
|
[LinkChannel.INSTAGRAM]: '인스타그램',
|
|
[LinkChannel.OFFICIAL_SITE]: '공식 사이트',
|
|
[LinkChannel.BLOG]: '블로그',
|
|
[LinkChannel.ETC]: '채널',
|
|
};
|
|
|
|
export function channelLabel(link: ChannelLink): string {
|
|
return link.title ?? CHANNEL_LABEL[link.channel] ?? '채널';
|
|
}
|
|
|
|
/**
|
|
* 예약 버튼에 적는 이름.
|
|
*
|
|
* ★ `channelLabel()` 은 링크 제목을 우선하는데, 수집된 제목은 "스테이,머뭄 네이버 플레이스"처럼
|
|
* 사업장 이름을 달고 온다. 버튼에 그대로 실으면 "스테이,머뭄 네이버 플레이스 예약" 이 된다 —
|
|
* 버튼이 답해야 하는 건 '어디서 예약하나' 하나다. 목록(예약 안내)에서는 제목이 맞다.
|
|
*/
|
|
export function bookingLabel(link: ChannelLink): string {
|
|
return CHANNEL_LABEL[link.channel] ?? channelLabel(link);
|
|
}
|
|
|
|
/**
|
|
* 예약을 실제로 받는 채널.
|
|
*
|
|
* ★ 블로그·인스타그램은 그 목록에 없다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기"
|
|
* 자리에 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 없다 — 지금 보고 있는 이
|
|
* 사이트가 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다.
|
|
* ★ 화면의 예약 버튼과 JSON-LD 의 `makesOffer.url`·`potentialAction` 이 **같은 링크**를
|
|
* 가리켜야 한다. 목록을 두 곳에 적으면 그게 조용히 갈라진다.
|
|
*/
|
|
|
|
/**
|
|
* 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만.
|
|
*
|
|
* ★ 블로그·공식 사이트·기타(ETC)는 뺀다. 읽기만 되는 링크를 "문의" 버튼으로 두면
|
|
* 손님이 남긴 말이 아무 데도 도착하지 않는다.
|
|
*/
|
|
const CONTACT_CHANNELS: readonly number[] = [LinkChannel.NAVER_PLACE, LinkChannel.INSTAGRAM];
|
|
|
|
/** 확정된 링크만. 확정 전 URL 은 동명 업소일 수 있다(sanitizePayloadForPublish 와 같은 규칙). */
|
|
function confirmedLinks(payload: SitePayload, channels: readonly number[]): ChannelLink[] {
|
|
return payload.links.filter((link) => link.confirmed && channels.includes(link.channel));
|
|
}
|
|
|
|
/**
|
|
* 예약 버튼에 낼 채널. **BOOKING_CHANNELS 순서대로** 정렬한다 — 예약 화면으로 바로 가는
|
|
* 채널이 맨 위 버튼이어야 한다.
|
|
*
|
|
* ★ 검색 결과 주소는 뺀다. 자동 발견이 `map.naver.com/p/search/…` 를 물어오는 경우가 있고
|
|
* (실측 2026-09-08), 그걸 "예약" 버튼에 걸면 손님이 검색 화면을 만난다 — 예약하러 온
|
|
* 사람에게 검색 결과를 주는 건 링크가 없는 것보다 나쁘다. 가게를 특정하지 못하는 주소라
|
|
* 애초에 예약 창구가 아니다.
|
|
*/
|
|
export function bookingLinks(payload: SitePayload): ChannelLink[] {
|
|
const order = new Map(BOOKING_CHANNELS.map((channel, index) => [channel, index]));
|
|
return confirmedLinks(payload, BOOKING_CHANNELS)
|
|
.filter((link) => !isSearchUrl(link.url))
|
|
.sort((a, b) => (order.get(a.channel) ?? 99) - (order.get(b.channel) ?? 99));
|
|
}
|
|
|
|
/** 가게가 아니라 **검색 결과**를 가리키는 주소인지. */
|
|
function isSearchUrl(url: string): boolean {
|
|
return /\/p\/search\/|[?&]query=/.test(url);
|
|
}
|
|
|
|
/**
|
|
* ─────────────────────────────────────────────────────────────────────────
|
|
* 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터.
|
|
* ─────────────────────────────────────────────────────────────────────────
|
|
*
|
|
* ★ 왜 숙박만 따로 만드나
|
|
* `bookingRows()` 가 읽는 `reservation_required`·`reservation_channel` 은 **숙박 스키마에
|
|
* 없는 key** 다(lodging.json 확인). 그래서 숙박으로 발행하면 서버 기본표가 "실시간 예약"
|
|
* 섹션을 켜 두는데도(`site_payload._DEFAULT_THEME`) 화면에는 전화번호 한 줄만 남았다 —
|
|
* 요금도, 인원도, 취소 규정도, 예약 창구도 없는 "예약" 섹션이었다.
|
|
* 펜션·민박은 예약이 곧 매출이고, AI 가 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 에
|
|
* 답할 근거가 이 자리에 있어야 한다.
|
|
*
|
|
* ★ 우리는 예약을 **처리하지 않는다.** 빈 방 재고도 결제도 갖지 않고(PRODUCT.md 6절),
|
|
* 확정된 예약 채널과 전화로 **보낸다.** 그래서 이 구성은 "예약 폼" 이 아니라
|
|
* **"예약에 필요한 사실 + 실제로 예약이 되는 창구"** 다. 없는 기능을 화면으로 흉내내면
|
|
* 손님은 예약한 줄 알고 안 온다.
|
|
*/
|
|
|
|
/**
|
|
* 예약 전에 반드시 확인해야 하는 fact.
|
|
*
|
|
* ★ 이용 규정(`RULE_FACT_KEYS`)과 목록이 겹친다 — 일부러다. 같은 사실이라도 손님이 그것을
|
|
* 찾는 순간이 다르다(규정은 "어떤 곳인가", 여기는 "예약을 눌러도 되는가"). 두 섹션이
|
|
* 같이 켜져 있으면 값이 두 번 보이는데, 값이 같으므로 거짓이 되지 않는다.
|
|
* ★ 프런트 운영시간을 넣는다 — 전화 예약이 1순위인 업소에서 "언제 전화하면 받나" 는
|
|
* 예약 성공 여부를 가르는 값이다.
|
|
*/
|
|
const STAY_BOOKING_NOTICE_KEYS = [
|
|
'check_in_time',
|
|
'check_out_time',
|
|
'cancel_policy',
|
|
'extra_person_fee',
|
|
'reception_hours',
|
|
'cooking_allowed',
|
|
'pet_allowed',
|
|
'smoking',
|
|
] as const;
|
|
|
|
/** 예약 창구 한 줄에 필요한 객실 정보. */
|
|
export interface StayOffer {
|
|
unitId: string;
|
|
name: string;
|
|
/** "기준 2명 · 최대 4명". 확인된 값만으로 만들고, 둘 다 없으면 undefined. */
|
|
capacityText?: string;
|
|
/** 주중·주말·성수기 요금. 확인된 것만. */
|
|
rateRows: InfoRow[];
|
|
/**
|
|
* 기준 요금 표기("주중 1박 280,000원").
|
|
*
|
|
* ★ 숫자는 `unitBaseRate`(seo/jsonld.ts)가 고른 그 값이다 — JSON-LD 의
|
|
* `makesOffer.price` 와 **같은 숫자**여야 화면 ↔ 구조화 데이터 대조를 통과한다.
|
|
*/
|
|
baseRateText?: string;
|
|
/** 객실 상세(사진·전체 스펙)는 객실 섹션이 갖고 있다. 한 장 사이트라 앵커다. */
|
|
href: string;
|
|
}
|
|
|
|
function stayOffers(payload: SitePayload): StayOffer[] {
|
|
return sanitizeUnits(payload.units).map((unit) => {
|
|
const standard = factText(unit.facts, 'standard_capacity');
|
|
const max = factText(unit.facts, 'max_capacity');
|
|
const rate = unitBaseRate(unit);
|
|
|
|
return {
|
|
unitId: unit.unitId,
|
|
name: unit.name,
|
|
capacityText: [standard && `기준 ${standard}`, max && `최대 ${max}`]
|
|
.filter(Boolean)
|
|
.join(' · ') || undefined,
|
|
rateRows: (['weekday_price', 'weekend_price', 'peak_price'] as const)
|
|
.map((key) => {
|
|
const value = factText(unit.facts, key);
|
|
const label = unit.facts.find((f) => f.key === key)?.label ?? key;
|
|
return value ? {label, value} : null;
|
|
})
|
|
.filter((row): row is InfoRow => row !== null),
|
|
baseRateText: rate ? `${rate.label} ${rate.price.toLocaleString('ko-KR')}원` : undefined,
|
|
href: '#units',
|
|
};
|
|
});
|
|
}
|
|
|
|
export interface StayBookingView {
|
|
offers: StayOffer[];
|
|
notices: InfoRow[];
|
|
/** 실제로 예약이 되는 채널. 확정된 것만. */
|
|
links: ChannelLink[];
|
|
/** 말을 걸 수 있는 채널(네이버 톡톡·인스타 DM). */
|
|
contacts: ChannelLink[];
|
|
phone?: string;
|
|
}
|
|
|
|
/**
|
|
* 숙박 예약 구성에 필요한 것 전부. **근거가 하나도 없으면 null** 이다.
|
|
*
|
|
* ★ null 을 돌려주는 이유: 섹션을 그릴지 말지를 컴포넌트·상단 내비·하단 탭이 각자
|
|
* 판단하면 세 곳이 갈라진다. 눌러도 아무 일 없는 "예약" 탭은 고장으로 읽힌다.
|
|
* 판단은 이 함수 하나가 한다.
|
|
*/
|
|
export function stayBookingView(payload: SitePayload): StayBookingView | null {
|
|
if (payload.place.category !== PlaceCategory.LODGING) return null;
|
|
|
|
const view: StayBookingView = {
|
|
offers: stayOffers(payload).filter(
|
|
(offer) => offer.rateRows.length > 0 || offer.capacityText !== undefined,
|
|
),
|
|
notices: placeRowsByKeys(payload, STAY_BOOKING_NOTICE_KEYS),
|
|
links: bookingLinks(payload),
|
|
// ★ 예약 창구로 이미 나가는 채널은 문의에 다시 넣지 않는다. 네이버 플레이스는 두
|
|
// 목록에 모두 들어 있어서, 그대로 두면 같은 링크가 "예약" 과 "문의" 로 두 번 보인다 —
|
|
// 손님은 둘이 다른 곳인 줄 알고 어느 쪽을 눌러야 하는지 망설인다.
|
|
contacts: contactLinks(payload).filter(
|
|
(contact) => !bookingLinks(payload).some((link) => link.url === contact.url),
|
|
),
|
|
phone: payload.place.phone,
|
|
};
|
|
|
|
const empty =
|
|
view.offers.length === 0 &&
|
|
view.notices.length === 0 &&
|
|
view.links.length === 0 &&
|
|
view.contacts.length === 0 &&
|
|
!view.phone;
|
|
|
|
return empty ? null : view;
|
|
}
|
|
|
|
/**
|
|
* 섹션 설정에 그 섹션 자체가 있는지.
|
|
*
|
|
* ★ `isSectionEnabled()` 와 다르다 — "사장님이 껐다" 와 "payload 에 항목이 아예 없다" 는
|
|
* 다른 상태다. 항목이 없는 payload(옛 버전·손으로 만든 fixture)에서는 기본으로 내보내고,
|
|
* **명시적으로 끈 것은 존중한다.** 둘을 같이 묶으면 사장님이 끈 섹션이 되살아난다.
|
|
*/
|
|
export function hasSection(payload: SitePayload, id: string): boolean {
|
|
return payload.theme.sections.some((section) => section.id === id);
|
|
}
|
|
|
|
export function contactLinks(payload: SitePayload): ChannelLink[] {
|
|
return confirmedLinks(payload, CONTACT_CHANNELS);
|
|
}
|
|
|
|
/* ── 숙박 예약(origin/main 의 feature/stay-booking) ─────────────────────
|
|
* ★ 이 블록은 예약 작업이 넣은 것이다. DB 재구성 병합 때 derive.ts 를 이쪽 버전으로
|
|
* 가져오면서 빠질 뻔했다 — 예약 화면(StayBookingSection)이 이 셋에 의존한다. */
|
|
/**
|
|
* "○○ 예약" 한 줄. 라벨이 이미 '예약' 으로 끝나면 그대로 둔다.
|
|
*
|
|
* ★ 실측(2026-09-09, 스테이,머뭄): 네이버 예약 채널이 붙자 버튼이 **"네이버 예약 예약"** 이 됐다.
|
|
* `{bookingLabel(link)} 예약` 을 네 곳에서 각자 이어 붙이고 있었기 때문이다 —
|
|
* 문구를 만드는 자리가 여럿이면 채널이 하나 늘 때 그중 몇 곳만 고쳐진다.
|
|
*/
|
|
export function bookingActionLabel(link: ChannelLink, prefix?: string): string {
|
|
const name = bookingLabel(link);
|
|
const phrase = name.endsWith('예약') ? name : `${name} 예약`;
|
|
return prefix ? `${prefix} ${phrase}` : phrase;
|
|
}
|
|
|
|
/**
|
|
* 예약 버튼에 찍을 말.
|
|
*
|
|
* ★ `${channelLabel}에서 예약` 로 일괄 처리하면 네이버 예약이 "네이버 예약에서 예약" 이 된다.
|
|
* 그리고 이 채널은 다른 채널과 성격이 다르다 — 누르면 **예약 화면 그 자체**가 뜬다.
|
|
* 그 차이를 버튼이 말해 줘야 손님이 한 번 더 눌러야 하는지 아닌지를 안다.
|
|
*/
|
|
export function bookingCtaLabel(link: ChannelLink): string {
|
|
if (link.channel === LinkChannel.NAVER_BOOKING) return '네이버 예약으로 바로 예약하기';
|
|
return `${channelLabel(link)}에서 예약`;
|
|
}
|