여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
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<string>();
|
|
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<T extends object>(payload: SitePayload, id: string) {
|
|
const section = payload.theme.sections.find((entry) => entry.id === id);
|
|
const parsed = parseSectionData<T>(id, section?.data);
|
|
|
|
/* 사장님이 쓴 것 + 우리가 지역 단위로 만든 것을 **한 배열로 잇는다** 가요·인물·연표·엽서·퀴즈는 업장의 사실이 아니라 도시의 사실이라 지역에 한 벌만 두고 같은 지역 사이트가 나눠 쓴다(`payload.local.story`, 서버는 `area_contents`). */
|
|
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`)을 그대로 쓴다. */
|
|
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<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] ?? '채널';
|
|
}
|
|
|
|
/** 예약 버튼에 적는 이름. */
|
|
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)}에서 예약`;
|
|
}
|