import { LinkChannel, PlaceCategory, displayFactLabel, factText, parseSectionData, sanitizeUnits, selectPublishable, selectPublishableFaqs, type ChannelLink, type FactEntry, type MediaItem, type SitePayload, type UnitInfo, type WeatherBand, type WeatherMood, } from '@o2o/shared'; import {UNIT_SPEC, bookingChannelLinks, primaryChannelLink, publicLinks, unitBaseRate} from '@site/seo/jsonld'; // 링크 거르기는 jsonld 에 산다(순환 의존 회피) — 화면 쪽은 늘 derive 를 통해 쓰므로 여기서 다시 낸다. export {publicLinks, primaryChannelLink}; /** payload → 화면이 바로 쓰는 모양. */ export interface InfoRow { key?: string; label: string; value: string; /** 부가 설명. */ note?: string; } /** 사업장 단위 이용 정보 표. */ export function essentialRows(payload: SitePayload): InfoRow[] { return selectPublishable(payload.facts) .filter((fact) => fact.scope === 'place') .map((fact) => ({ key: fact.key, label: displayFactLabel(fact), value: displayValue(fact, payload.facts), })) .filter((row) => row.value !== ''); } function displayValue(fact: FactEntry, all: FactEntry[]): string { // 두 이용 정보 영역은 같은 소개 요약을 쓴다. if (fact.key === 'intro' && fact.summary?.trim()) return fact.summary.trim(); 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']); function dedupeRows(rows: InfoRow[]): InfoRow[] { const seen = new Set(); return rows.filter((row) => { const key = row.value.trim(); if (seen.has(key)) return false; seen.add(key); return true; }); } 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 ? ['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), rows: dedupeRows( unit.facts .filter((fact) => !UNIT_PRICE_FACT_KEYS.has(fact.key)) .filter((fact) => !chipKeys.includes(fact.key)) .filter((fact) => fact.key !== 'room_intro' && fact.key !== 'description') .map((fact) => ({label: displayFactLabel(fact), value: displayValue(fact, unit.facts)})) .filter((row) => row.value !== '' && row.value.trim() !== (intro ?? '').trim()), ), 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}`, }; }); } /** 카드에 얹는 "얼마부터". */ function unitPriceText(unit: UnitInfo): string | undefined { /* 예약 채널이 **범위로** 파는 방은 범위 그대로 낸다 머뭄은 네이버에 "198,000 ~ 350,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')}원부터`; } /** 요금 세 칸 — 주중 · 주말 · 성수기. */ 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 weatherBand(temperature: number): WeatherBand { if (temperature >= 30) return '혹서'; if (temperature >= 25) return '더움'; if (temperature >= 20) return '선선'; if (temperature >= 10) 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); } /** 켜져 있는 섹션인지. */ 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 → 렌더 가능한 항목. */ export function sectionItems(payload: SitePayload, id: string) { const section = payload.theme.sections.find((entry) => entry.id === id); const parsed = parseSectionData(id, section?.data); /* 사장님이 쓴 것 + 우리가 지역 단위로 만든 것을 **한 배열로 잇는다** 가요·인물·연표·엽서·퀴즈는 업장의 사실이 아니라 도시의 사실이라 지역에 한 벌만 두고 같은 지역 사이트가 나눠 쓴다(`payload.local.story`, 서버는 `area_contents`). */ const shared = (payload.local.story as Record | 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`)을 그대로 쓴다. */ 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 를 표 행으로. */ 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 한 줄. */ export function placeRow(payload: SitePayload, key: string): InfoRow | undefined { return placeRowsByKeys(payload, [key])[0]; } /** 이용 규정으로 읽히는 fact key. */ export const RULE_FACT_KEYS = [ 'check_in_time', 'check_out_time', 'cancel_policy', 'cooking_allowed', 'pet_allowed', 'smoking', 'extra_person_fee', ] as const; /** 이용 규정 줄. */ export function ruleRows(payload: SitePayload): InfoRow[] { return placeRowsByKeys(payload, RULE_FACT_KEYS); } /** 예약 안내에 실을 fact. */ const BOOKING_FACT_KEYS = ['reservation_required', 'reservation_channel'] as const; export function bookingRows(payload: SitePayload): InfoRow[] { return placeRowsByKeys(payload, BOOKING_FACT_KEYS); } /** 공간 안내에 실을 fact — "자리가 어떻게 생겼나"에 답하는 것만. */ 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. */ 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); } /** 첫 화면에 세우는 핵심 값 — 손님이 예약을 결정하기 전에 제일 먼저 묻는 것. */ const HERO_FACT_KEYS = [ 'check_in_time', 'check_out_time', 'operating_hours', 'closed_days', 'parking', 'reservation_required', ] as const; export function heroFacts(payload: SitePayload): InfoRow[] { return placeRowsByKeys(payload, HERO_FACT_KEYS).slice(0, 4); } /** 가장 싼 값. */ 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)); } /** 채널 코드 → 사람이 읽는 이름. */ 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] ?? '채널'; } /** 예약 버튼에 적는 이름. */ export function bookingLabel(link: ChannelLink): string { return CHANNEL_LABEL[link.channel] ?? channelLabel(link); } /** 예약을 실제로 받는 채널. */ /** 문의를 실제로 받을 수 있는 채널 — 네이버 톡톡·인스타 DM 처럼 말을 걸 수 있는 곳만. */ const CONTACT_CHANNELS: readonly number[] = [LinkChannel.NAVER_PLACE, LinkChannel.INSTAGRAM]; /** 확정된 링크만. */ function confirmedLinks(payload: SitePayload, channels: readonly number[]): ChannelLink[] { return payload.links.filter((link) => link.confirmed && channels.includes(link.channel)); } /** 예약 버튼에 낼 채널. */ export const bookingLinks = bookingChannelLinks; /** ───────────────────────────────────────────────────────────────────────── 숙박 예약 — 손님이 "이 방을 이 값에 이 창구로" 예약할 수 있게 하는 데이터. */ /** 예약 전에 반드시 확인해야 하는 fact. */ 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명". */ capacityText?: string; /** 주중·주말·성수기 요금. */ rateRows: InfoRow[]; /** 기준 요금 표기("주중 1박 280,000원"). */ 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; } /** 숙박 예약 구성에 필요한 것 전부. */ 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.links.length === 0 && view.contacts.length === 0 && !view.phone; return empty ? null : view; } /** 섹션 설정에 그 섹션 자체가 있는지. */ 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) ───────────────────── */ /** "○○ 예약" 한 줄. */ export function bookingActionLabel(link: ChannelLink, prefix?: string): string { const name = bookingLabel(link); const phrase = name.endsWith('예약') ? name : `${name} 예약`; return prefix ? `${prefix} ${phrase}` : phrase; } /** 예약 버튼에 찍을 말. */ export function bookingCtaLabel(link: ChannelLink): string { if (link.channel === LinkChannel.NAVER_BOOKING) return '네이버 예약으로 바로 예약하기'; return `${channelLabel(link)}에서 예약`; }