import { LinkChannel, PlaceCategory, factText, parseSectionData, sanitizeUnits, selectPublishable, selectPublishableFaqs, type ChannelLink, type FactEntry, type MediaItem, type SitePayload, type UnitInfo, } from '@o2o/shared'; import {BOOKING_CHANNELS, UNIT_SPEC, unitBaseRate} from '@/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: fact.label, 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 (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; href: string; } 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 ? ['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), rows: unit.facts .map((fact) => ({label: fact.label, 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), href: `/${spec.path}/${unit.slug}`, }; }); } /** * 카드에 얹는 "얼마부터". * * ★ 숫자를 여기서 고르지 않는다 — `unitBaseRate`(seo/jsonld.ts) 하나가 고른 값을 표기만 한다. * 화면과 JSON-LD 가 각자 계산하면 어긋날 수 있고, 어긋나면 절대규칙 3 위반으로 발행이 막힌다. */ function unitPriceText(unit: UnitInfo): string | undefined { const rate = unitBaseRate(unit); return rate ? `${rate.price.toLocaleString('ko-KR')}원부터` : undefined; } /** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */ 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(payload: SitePayload, id: string) { const section = payload.theme.sections.find((entry) => entry.id === id); return parseSectionData(id, section?.data); } 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 용이지

용이 아니다. * * ★ 폴백을 두는 이유 * name 이 비어 있는 payload(옛 버전·손으로 만든 fixture)에서 제목 없는

가 * 나가면 문서 구조가 무너지고 검색·낭독기가 섹션을 못 읽는다. 제목은 반드시 채운다. */ 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: fact.label, value: displayValue(fact, payload.facts)})) .filter((row) => row.value !== ''); } /** * 이용 규정으로 읽히는 fact key. * * ★ 관리자 캔버스의 `builder/canvas/variants/common.ts` RULE_FIELD_IDS 와 같은 목록이다. * 에디터에서 규정으로 보인 항목이 발행본에서 다른 항목이 되면 사장님은 어느 쪽을 * 믿어야 할지 모른다. 목록이 바뀌면 양쪽을 같이 고친다. * ★ 여기 없는 규정은 만들어 내지 않는다. 업종 스키마(lodging.json)에 있는 key 만 적는다. */ 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); } /** 채널 코드 → 사람이 읽는 이름. link.title 이 있으면 그쪽이 우선이다. */ export const CHANNEL_LABEL: Record = { [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 bookingCtaLabel(link: ChannelLink): string { if (link.channel === LinkChannel.NAVER_BOOKING) return '네이버 예약으로 바로 예약하기'; return `${channelLabel(link)}에서 예약`; } /** * 예약을 실제로 받는 채널 목록은 `seo/jsonld.ts` 의 `BOOKING_CHANNELS` 한 벌이다. * * ★ 블로그·인스타그램은 그 목록에 없다. 눌러도 예약 화면이 안 나오는 링크를 "예약하기" * 자리에 두면 손님이 예약한 줄 알고 안 온다. 공식 사이트도 없다 — 지금 보고 있는 이 * 사이트가 그 자리라, 자기 자신으로 돌려보내는 버튼이 된다. * ★ 화면의 예약 버튼과 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); }