o2o-site-AEO/solution/site/src/layouts/oasi/Hero.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

110 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useSite} from '@site/lib/site-context';
import {primaryImage} from '@site/seo/meta';
import {bookingActionLabel, bookingLinks, essentialRows, heroFacts, lowestPrice} from '@site/lib/derive';
/**
* oasi 첫 화면 — 사진 한 장과 아주 작은 글씨.
*
* ★ 사진 위에 글자를 얹지 않는다. 이 안은 폭을 제한한 사진을 가운데 한 장 놓고
* **바깥 여백에 세로쓰기 캡션**을 세우는 편집형이다 — 사진을 화면 끝까지 늘리면
* 그 여백이 사라져 안 자체가 성립하지 않는다.
* ★ 상호는 `<h1>` 이지만 크게 쓰지 않는다. 크기는 왼쪽 워드마크가 이미 맡았고,
* 여기서 큰 활자를 한 번 더 쓰면 화면에 큰 글자가 둘이 된다.
* ★ 세로쓰기 캡션은 좁은 화면에서 감춘다 — 390px 에서는 사진 밖 여백이 없어
* 그대로 두면 가로 스크롤이 생긴다.
* ★ 사진 틀의 **높이를 고정한다**. 자연 비율로 두면 소스가 세로 사진일 때 첫 화면을
* 사진 하나가 통째로 먹는다 — 실측(stay3, 1000×1333): 544×725px.
* 사장님이 어떤 사진을 올리든 첫 화면 높이가 같아야 한다.
*/
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const image = primaryImage(payload);
const price = lowestPrice(payload);
const links = bookingLinks(payload);
/* 첫 화면 우선순위 key(체크인·영업시간…)가 하나도 없는 업종이 있다 —
그때 띠를 비우지 않고 확인된 이용 정보 앞 넷으로 채운다. */
const priority = heroFacts(payload);
const facts = priority.length > 0 ? priority : essentialRows(payload).slice(0, 4);
const caption = image?.caption ?? place.name;
return (
/* data-oasi: 껍데기의 판 지우개(Shell PLATE_CSS)를 건너뛴다 — 여기는 이미 투명하고
위 여백도 스스로 정한다(첫 섹션이라 다른 섹션보다 좁다). */
<section id="top" data-oasi aria-labelledby="top-heading" className="pt-12 lg:pt-16">
<h1
id="top-heading"
className="text-[length:var(--fs-sm)] font-normal tracking-[0.08em] leading-[1.9]"
>
{place.name}
</h1>
{narrative.heroHeadline && (
<p className="text-muted measure mt-3 text-[length:var(--fs-xs)] leading-[1.9]">
{narrative.heroHeadline}
</p>
)}
{image && (
<figure className="relative mx-auto mt-10 w-full max-w-[34rem]">
<img
src={image.url}
alt={image.alt}
// 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다.
fetchPriority="high"
decoding="async"
width={image.width}
height={image.height}
className="block h-[clamp(17rem,27vw,24rem)] w-full object-cover"
/>
{/* 캡션 높이를 액자에 묶고(max-h-full) 한 줄로 묶는다(nowrap).
22rem 로 박아 두면 액자보다 캡션이 길어져 사진 아래로 흘러내리고,
긴 캡션은 세로쓰기가 여러 단으로 갈려 액자 옆이 글자 벽이 된다. */}
<figcaption
className="text-muted absolute left-full top-0 ml-5 hidden max-h-full overflow-hidden text-ellipsis whitespace-nowrap text-[length:var(--fs-xs)] tracking-[0.14em] xl:block"
style={{writingMode: 'vertical-rl'}}
>
{caption}
</figcaption>
</figure>
)}
{(price || facts.length > 0) && (
<dl className="border-line mt-10 flex flex-wrap gap-x-10 gap-y-4 border-t pt-5 text-[length:var(--fs-xs)] leading-[1.9]">
{price && (
<div>
<dt className="text-muted">최저가</dt>
<dd>{price}</dd>
</div>
)}
{facts.map((row) => (
<div key={row.label}>
<dt className="text-muted">{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
)}
{/* 확정된 예약 채널만. 버튼 상자를 만들지 않는다 — 이 안에는 면이 없다. */}
{links.length > 0 && (
<ul className="mt-6 flex flex-wrap items-center gap-x-8">
{links.map((link) => (
<li key={link.url}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]"
style={{color: 'var(--color-brand)'}}
>
{bookingActionLabel(link)}
</a>
</li>
))}
</ul>
)}
</section>
);
}