import { LinkChannel, PlaceCategory, factText, sanitizeUnits, selectPublishable, selectPublishableFaqs, type ChannelLink, type FactEntry, type MediaItem, type SitePayload, type UnitInfo, } from '@o2o/shared'; import {UNIT_SPEC} 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}`, }; }); } function unitPriceText(unit: UnitInfo): string | undefined { 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')}원부터`; } /** 갤러리에 낼 이미지 — 대체 텍스트 없는 것은 뺀다(검색·낭독기 모두 못 읽는다). */ 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) ?? []; } 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 용이지