o2o-site-AEO/solution/site/src/sections/AnswerBlock.tsx
Mina Choi b1386dd3ce [refactor] solution,nginx: 에디터와 발행본을 한 렌더러로 — 보는 그대로 나간다
에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):

  발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
  (gallery↔photos · location↔map · guide↔local)
  겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)

소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.

**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.

**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.

**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).

**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.

**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.

곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
  이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
  일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
  붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
  src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**

검증(Playwright, 같은 사업장·1024px):
  섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
  15개 전부 높이·글자 수·제목이 정확히 같다
  편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
  `/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:09:28 +09:00

72 lines
3.1 KiB
TypeScript

import {useSite} from '@site/lib/site-context';
import {factualSummary} from '@site/seo/meta';
import {essentialRows} from '@site/lib/derive';
import {formatKoreanDate} from '@site/lib/format';
/**
* 핵심 정보 요약 — AEO 의 핵심 블록.
*
* AI 검색은 페이지 전체를 요약하지 않는다. "답이 될 만한 문장"을 찾아 그대로 인용한다.
* 그래서 상단에 단정문을 둔다: "체크인은 16:00입니다." 형태.
* 표(dl)로 두는 이유는 label-value 관계가 마크업으로 드러나야 기계가 짝을 짓기 때문이다.
*
* ★ 여기 나오는 값은 전부 확인된 fact 다. essentialRows() 가 걸러 준다.
* ★ 히어로 바로 아래라 섹션 여백을 통째로 쓰지 않는다(Section 을 안 쓰는 유일한 섹션).
* 첫 화면과 이 블록 사이가 벌어지면 "답"이 접힌 화면 밖으로 밀린다.
*/
export function AnswerBlock() {
const payload = useSite();
const rows = essentialRows(payload).slice(0, 6);
const summary = payload.narrative.summary ?? factualSummary(payload);
if (rows.length === 0 && !summary) return null;
return (
<section
id="summary"
aria-labelledby="summary-heading"
className="border-line w-full border-b py-10 sm:py-14"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
>
<div className="shell">
<div className="grid gap-8 lg:grid-cols-12 lg:gap-12">
<div className="lg:col-span-5">
{/* ★ "한눈에 보는 ○○" 같은 문구를 걷어냈다 — 국내 사이트가 안 쓰는 말투다. */}
<h2 id="summary-heading" className="h3">
예약 전 확인
</h2>
{/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
{summary && (
<p className="measure mt-3 text-[length:var(--fs-body)] leading-relaxed">{summary}</p>
)}
<p className="text-muted mt-3 text-[length:var(--fs-xs)]">
{formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다.
</p>
</div>
{rows.length > 0 && (
<dl className="grid grid-cols-1 gap-x-10 lg:col-span-7 xl:grid-cols-2">
{/*
★ 값을 오른쪽에 붙이지 않는다. 긴 문장(환불 규정)이 오면 라벨과 멀어진 채
세 줄로 감겨서, 어느 값이 어느 항목인지 눈이 짝을 못 짓는다.
라벨 폭을 고정하고 값은 왼쪽에서 시작한다.
*/}
{rows.map((row) => (
<div
key={row.label}
className="border-line flex flex-col gap-0.5 py-2.5 sm:flex-row sm:gap-4 sm:border-b"
>
<dt className="text-muted shrink-0 text-[length:var(--fs-sm)] sm:w-28">
{row.label}
</dt>
<dd className="text-[length:var(--fs-sm)] font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
</div>
</div>
</section>
);
}