o2o-site-AEO/solution/site/src/sections/EssentialInfoSection.tsx
Mina Choi f2087aad5e feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리
상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다.

사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
2026-09-15 16:12:16 +09:00

215 lines
9.5 KiB
TypeScript

import {Phone} from 'lucide-react';
import {selectPublishable} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, essentialRows} from '@site/lib/derive';
import {factualSummary} from '@site/seo/meta';
import {formatKoreanDate} from '@site/lib/format';
import type {InfoRow} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
/**
* 이용 정보 표 전체.
*
* 예약 전 확인은 이 섹션 한 곳에서 확인된 항목 전체를 낸다.
* ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면
* 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다.
*
* ★ 이용 규정을 여기로 들여왔다 (2026-09-03)
* 따로 '이용 규정' 섹션이 있었는데, 그 줄(체크인·체크아웃·취소규정·취사·반려동물·흡연·
* 추가요금)이 **이 표의 완전한 부분집합**이었다 — 같은 fact 를 두 섹션이 두 번 그렸다.
* 나눈 이유는 "취소·환불은 몰랐다가 클레임이 나니 따로 세운다"였는데, 양쪽에 다 나오니
* 그 목적도 못 이루고 중복만 남았다. 한 섹션 안에서 **규정만 따로 묶어** 눈에 띄게 한다.
*
* ★ 카드 격자를 버리고 **줄 목록 하나**로 통일했다 (2026-09-07, 사장님 지시: "박스라서 가독성이 떨어진다")
* 값이 짧으면 항목마다 테두리 상자를 세워 2~4열로 벌렸는데, 상자가 상자 안에 들어가
* 테두리가 두 겹이 되고 — 정작 읽어야 할 것은 라벨과 값 한 쌍뿐인데 — 눈이 상자를 먼저 셌다.
* 값의 길이로 열 수를 바꾸는 규칙도 같이 버린다. 같은 섹션 안에서 규정 덩이는 목록,
* 시설 덩이는 격자로 갈리니 한 화면에 두 가지 문법이 섰다.
* 이용안내는 훑는 화면이다 — 라벨이 한 열에 세로로 정렬돼야 값끼리 비교가 된다.
/**
* 규정으로 묶을 항목의 이름.
*
* ★ `essentialRows` 는 key 가 아니라 사람이 읽는 label 을 준다(업종 스키마가 붙인 이름).
* 그래서 key 목록이 아니라 label 로 가른다 — 스키마가 이름을 바꾸면 여기도 같이 고친다.
*/
const RULE_LABELS = new Set([
'체크인 시간',
'체크아웃 시간',
'취소·환불 규정',
'취사 가능',
'반려동물 동반',
'흡연 가능',
'인원 추가 요금',
]);
export function EssentialInfoSection() {
const payload = useSite();
const rows = essentialRows(payload);
const intro = selectPublishable(payload.facts).find((fact) => fact.key === 'intro');
const summary = intro?.summary?.trim() || payload.narrative.summary || factualSummary(payload);
const guides = payload.links.filter((link) => link.confirmed &&
[link.stayGuide?.policy, link.stayGuide?.service, link.stayGuide?.reservation].some((text) => text?.trim()));
const structured = guides.flatMap((link) => link.stayGuide?.fields ?? []);
const mergedRows = [...rows];
const seen = new Set(rows.map((row) => row.key));
for (const field of structured) {
// 직접 입력한 노출값을 우선한다. 같은 항목을 출처마다 반복하지 않는다.
if (!seen.has(field.key)) {
mergedRows.push(field);
seen.add(field.key);
}
}
const isRule = (row: InfoRow) => {
if (['cooking_allowed', 'smoking'].includes(row.key ?? '')) return false;
return RULE_LABELS.has(row.label) || structured.some((field) => field.key === row.key && field.group === 'rules');
};
const hiddenCount = payload.facts.filter(
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
).length;
if (rows.length === 0 && guides.length === 0) return null;
/**
* 규정과 나머지를 가른다.
*
* ★ 순서를 뒤집지 않는다 — 규정이 먼저다. 예약 전에 알아야 클레임이 안 난다.
*/
const ruleRows = [
...mergedRows.filter(isRule),
...guides.filter((link) => !link.stayGuide?.fields?.length && link.stayGuide?.policy).map((link) => ({
label: 'NOL 이용 안내', value: link.stayGuide?.policy ?? '',
})),
];
const otherRows = [
...mergedRows.filter((row) => !isRule(row)),
...guides.filter((link) => !link.stayGuide?.fields?.length && link.stayGuide?.service).map((link) => ({
label: 'NOL 시설 안내', value: link.stayGuide?.service ?? '',
})),
];
return (
<Section
id="info"
tone="alt"
/* ★ 예약 섹션을 여기로 합쳤다 (2026-09-03, 사장님 지시)
"이용 및 예약 안내" 와 "예약 안내" 두 섹션이 목차에 나란히 떠서 손님은 어느 쪽에서
예약하는지 몰랐다. 이용 정보를 읽고 그 자리에서 바로 누르는 게 맞다. */
title="이용안내 및 예약"
lead="방문 전 확인이 필요한 운영 규정과 시설 안내입니다."
footnote={
<span className="flex flex-col gap-1 sm:flex-row sm:justify-between">
<span>
{hiddenCount > 0
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
: '등록 정보와 수집한 안내를 기준으로 표시합니다.'}
</span>
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span>
</span>
}
>
<div className="space-y-8">
{summary && <p className="measure text-[length:var(--fs-body)] leading-relaxed">{summary}</p>}
{ruleRows.length > 0 && <Rows title="예약 전 확인" rows={ruleRows} emphasis />}
{otherRows.length > 0 && <Rows title="시설 · 편의" rows={otherRows} />}
{guides.filter((link) => link.stayGuide?.reservation).map((link) => (
<Rows key={`reservation-${link.url}`} title="예약 공지" emphasis rows={[
{label: 'NOL 예약 공지', value: link.stayGuide?.reservation ?? ''},
]} />
))}
<BookingRow />
</div>
</Section>
);
}
/**
* 줄 목록 한 덩이.
*
* ★ 값이 길면 카드 격자가 아니라 **표**다. 1열 격자에 카드를 쌓으면 카드 하나가
* 1200px 폭을 가로지르고 오른쪽이 통째로 빈다 — 국내 사이트의 이용안내는 예외 없이
* 한 판 안의 줄 목록이다.
* ★ 규정 덩이(emphasis)는 강조색 선을 왼쪽에 세운다. 몰랐다가 클레임이 나는 항목이다.
*/
function Rows({title, rows, emphasis}: {title: string; rows: InfoRow[]; emphasis?: boolean}) {
return (
<section>
<h3
className="border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold"
>
{emphasis && (
<i
aria-hidden
className="h-[1em] w-[3px] shrink-0"
style={{backgroundColor: 'var(--color-accent)'}}
/>
)}
<span>{title}</span>
</h3>
{/* ★ 테두리는 판 하나에만 준다. 줄 사이는 선(divide)이지 상자가 아니다. */}
<dl className="divide-line divide-y">
{rows.map((row, index) => (
<div
key={`${row.label}-${index}`}
className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6"
>
<dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0">
{row.label}
</dt>
{/* measure: 긴 규정 문장이 화면 끝까지 늘어나면 다음 줄 첫 글자를 못 찾는다. */}
<dd className="measure min-w-0 whitespace-pre-line break-words text-[length:var(--fs-sm)] font-semibold">
{row.value}
{row.note && <span className="text-muted mt-0.5 block font-normal">{row.note}</span>}
</dd>
</div>
))}
</dl>
</section>
);
}
/**
* 예약 창구 — 전화와 확정 채널.
*
* ★ 따로 서 있던 '예약 안내' 섹션을 이 자리로 들여왔다. 규정을 읽은 **바로 그 자리**에
* 누를 것이 있어야 한다 — 아래로 한 번 더 내려가야 하면 절반은 안 내려간다.
* ★ 확정된 채널만 나간다. 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다.
*/
function BookingRow() {
const payload = useSite();
const links = bookingLinks(payload);
const phone = payload.place.phone;
if (!phone && links.length === 0) return null;
return (
<div className="border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[length:var(--fs-sm)] font-semibold">
{payload.place.name} 예약은 아래로 받습니다.
</p>
<div className="flex flex-wrap gap-2">
{phone && (
<a
href={`tel:${phone}`}
className="tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
>
<Phone className="size-4" />
<span>{phone}</span>
</a>
)}
{links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{bookingActionLabel(link)}
</a>
))}
</div>
</div>
);
}