o2o-site-AEO/solution/site/src/layouts/editorial/Hero.tsx
김성경 a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00

164 lines
6.7 KiB
TypeScript

import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {primaryImage} from '@site/seo/meta';
import {
bookingActionLabel,
bookingLinks,
contactLinks,
channelLabel,
heroFacts,
lowestPrice,
unitSpec,
unitViews,
} from '@site/lib/derive';
import {GRAIN} from './SectionHead';
/**
* 편집(잡지)형 첫 화면 — 표지.
*
* ★ 사진은 오른쪽에 크게 걸고, 상호는 그 경계를 넘어 왼쪽 여백까지 흘러나온다.
* 겹침은 12칸 격자 위에서 만든다 — 사진과 상호 띠를 **같은 행 같은 칸**에 놓고
* 띠만 아래로 붙였다(absolute 를 쓰지 않아 모바일에서 자동으로 위아래로 풀린다).
* ★ 상호를 사진에 맨몸으로 얹지 않는다. 어느 사진의 어느 쪽이 밝은지 우리는 모른다 —
* 어두운 띠를 한 겹 깔고 그 위에 얹으면 어떤 사진에서도 대비가 남는다(88% 기준
* 흰 사진 위 최악값이 약 3.9:1, 큰 글자 기준 3:1 을 넘긴다).
* ★ 태그라인·요금·이용 정보는 사진 위가 아니라 **왼쪽 종이 면**에 둔다. 작은 글자는
* 3:1 로 부족하고(4.5:1 이 기준이다), 무엇보다 인용되는 문장을 사진에 태우면 안 된다.
*/
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const image = primaryImage(payload);
const spec = unitSpec(payload);
const units = unitViews(payload);
const facts = heroFacts(payload);
const price = lowestPrice(payload);
const booking = bookingLinks(payload)[0];
const contact = contactLinks(payload)[0];
const tagline = narrative.tagline ?? narrative.heroSubline;
// 검색창에 치는 이름을 먼저 쓴다 — "제주시" 보다 "애월" 이다.
const locality = place.addressSubLocality ?? place.addressLocality ?? place.addressRegion;
// 메타 한 줄 — 지역 · 객실 수 · 최저가. 확인된 값만 · 로 잇는다.
const meta = [
locality,
units.length > 0 ? `${spec.label} ${units.length}개` : undefined,
price,
].filter((part): part is string => Boolean(part));
return (
<section id="top" className="relative w-full" style={{backgroundImage: GRAIN}}>
<div className="grid grid-cols-12">
{image && (
<div
className="relative col-span-12 xl:col-span-8 xl:col-start-5 xl:row-start-1"
style={{minHeight: 'clamp(15rem, 52vh, 34rem)', backgroundColor: 'var(--tpl-inverse)'}}
>
<img
src={image.url}
alt={image.alt}
// 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다.
fetchPriority="high"
decoding="async"
width={image.width}
height={image.height}
className="absolute inset-0 size-full object-cover"
/>
</div>
)}
{/*
상호 띠 — 데스크톱에서는 격자 1~12칸 같은 행에 놓고 아래에 붙인다(self-end).
사진이 5~12칸이므로 왼쪽 4칸은 종이 위, 나머지는 사진 위를 지난다.
★ z-10: 격자에서 뒤에 오는 형제(왼쪽 칸)가 이 띠를 덮지 않게 한다.
*/}
<div
className="z-10 col-span-12 xl:col-start-1 xl:row-start-1 xl:mb-12 xl:mr-16 xl:self-end"
style={{
backgroundColor: 'color-mix(in srgb, var(--tpl-inverse) 88%, transparent)',
color: 'var(--tpl-bg)',
}}
>
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
<h1
className="serif px-4 py-5 sm:px-6 xl:py-8 xl:pl-10 xl:pr-16"
style={{
fontSize: 'clamp(2.25rem, 7.5vw, 5.5rem)',
fontWeight: 800,
lineHeight: 0.98,
letterSpacing: '-0.035em',
}}
>
{place.name}
</h1>
</div>
{/* 왼쪽 종이 면 — 읽는 값이 전부 여기 있다. 아래는 상호 띠가 지나가는 자리다. */}
{/* pb-56 은 상호 띠 높이(제목 5.5rem + 여백)에서 나온 값이다 — 줄이면 글자가 겹친다. */}
<div className="col-span-12 flex flex-col gap-5 px-4 py-8 sm:px-6 xl:col-span-4 xl:col-start-1 xl:row-start-1 xl:py-12 xl:pb-56 xl:pl-10 xl:pr-8">
{tagline && (
<p className="measure font-serif text-[length:var(--fs-lead)] leading-relaxed">
<HeroCatchphrase>{tagline}</HeroCatchphrase>
</p>
)}
{meta.length > 0 && (
<p className="text-[length:var(--fs-sm)] font-semibold tabular-nums">
{meta.join(' · ')}
</p>
)}
{/* 버튼을 세우지 않는다 — 잡지의 규칙은 밑줄 링크다. 대신 .tap 으로 높이를 지킨다. */}
{(booking || place.phone || contact) && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-bold">
{booking && (
<a
href={booking.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center underline underline-offset-4"
>
{bookingActionLabel(booking)}
</a>
)}
{place.phone && (
<a
href={`tel:${place.phone}`}
className="tap inline-flex items-center underline underline-offset-4"
>
{place.phone}
</a>
)}
{contact && (
<a
href={contact.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center underline underline-offset-4"
>
{channelLabel(contact)} 문의
</a>
)}
</div>
)}
{/* 확인된 값만. 없는 업종에서는 칸 자체가 안 생긴다. */}
{facts.length > 0 && (
<dl className="mt-1">
{facts.map((row) => (
<div
key={row.label}
className="border-line flex items-baseline justify-between gap-4 border-t py-2 text-[length:var(--fs-sm)]"
>
<dt className="text-muted shrink-0">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd>
</div>
))}
</dl>
)}
</div>
</div>
</section>
);
}