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

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

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

204 lines
8.0 KiB
TypeScript

import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive';
/**
* 파스텔의 첫 화면 — **연노랑 판 위에 둥근 사진**.
*
* ★ 사진을 쓴다 (2026-09-03, 두 번째 반려)
* 처음엔 상호를 담은 CSS 배지를 벽지처럼 깔았다. 레퍼런스(아이스크림 브랜드)는 그게 맞다 —
* **그쪽은 파는 게 브랜드고, 여기는 파는 게 집이다.** 손님이 첫 화면에서 보고 싶은 건
* 상호를 반복한 무늬가 아니라 방이 어떻게 생겼는가다. 파스텔은 사진을 감싸는 판으로 남기고,
* 원 무늬는 사진 뒤에 몇 개만 흘려 이 안의 결만 유지한다.
* ★ 사진 모서리를 크게 굴린다(--tpl-radius 가 24px 인 안이다). 각진 사진 하나만 딱딱해 보인다.
* ★ 장식 원은 aria-hidden 이고 사진 **뒤**에 둔다. 사진을 가리면 장식이 아니라 방해다.
*/
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const facts = heroFacts(payload);
const price = lowestPrice(payload);
const links = bookingLinks(payload);
const line = narrative.tagline ?? narrative.summary;
const locality = place.addressLocality ?? place.addressRegion;
// 대표 사진이 먼저. og:image 와 첫 장이 다르면 공유 카드와 화면이 어긋난다.
const shots = [...payload.media]
.filter((image) => image.alt?.trim())
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
.slice(0, 3);
// id="top" — 상단 워드마크와 하단 바가 여기로 돌아온다.
return (
<section id="top" data-pastel="own" className="w-full">
{/* 판 1 — 연노랑 바탕. 왼쪽에 말, 오른쪽에 사진. */}
<div className="relative w-full overflow-hidden" style={{backgroundColor: 'var(--pastel-1)'}}>
{/* 장식 원 — 사진 뒤에서 살짝만 삐져나온다. 화면 밖으로 잘려도 된다. */}
<div aria-hidden className="pointer-events-none absolute inset-0">
<span
className="absolute rounded-full"
style={{
width: 'clamp(12rem, 26vw, 22rem)',
aspectRatio: '1',
backgroundColor: 'var(--pastel-2)',
right: '-6%',
top: '-6%',
opacity: 0.75,
}}
/>
<span
className="absolute rounded-full"
style={{
width: 'clamp(8rem, 18vw, 15rem)',
aspectRatio: '1',
backgroundColor: 'var(--pastel-3)',
left: '46%',
bottom: '-10%',
opacity: 0.7,
}}
/>
</div>
<div
className="shell relative grid items-center gap-8 lg:grid-cols-2 lg:gap-14"
style={{paddingBlock: 'var(--section-space)'}}
>
<div className="order-2 lg:order-1">
{locality && (
<p
className="inline-flex items-center rounded-full px-3 py-1 text-[length:var(--fs-xs)] font-extrabold"
style={{backgroundColor: 'var(--tpl-bg, #fff)', color: 'var(--pastel-ink)'}}
>
{locality}
</p>
)}
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
<h1
className="mt-4 break-keep"
style={{
fontSize: 'var(--fs-display)',
fontWeight: 800,
lineHeight: 1.2,
letterSpacing: 'var(--tpl-heading-tracking, -0.03em)',
}}
>
{place.name}
</h1>
{line && (
<p className="measure mt-4 text-[length:var(--fs-lead)] font-medium leading-relaxed opacity-80">
<HeroCatchphrase>{line}</HeroCatchphrase>
</p>
)}
{links.length > 0 && (
<a
href={links[0].url}
target="_blank"
rel="noopener noreferrer"
className="tap mt-7 inline-flex items-center justify-center rounded-full px-8 text-[length:var(--fs-sm)] font-extrabold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--pastel-ink)', color: 'var(--tpl-bg, #fff)'}}
>
{bookingActionLabel(links[0])}
</a>
)}
</div>
<div className="order-1 lg:order-2">
{shots[0] && (
<div
className="relative w-full overflow-hidden"
style={{
aspectRatio: '1 / 1',
borderRadius: 'calc(var(--tpl-radius, 24px) * 1.6)',
// 그림자 대신 흰 테두리로 띄운다 — 이 안의 규칙이다.
boxShadow: '0 0 0 clamp(6px, 1.4vw, 12px) var(--tpl-bg, #fff)',
}}
>
<img
src={shots[0].url}
alt={shots[0].alt}
fetchPriority="high"
decoding="async"
className="size-full object-cover"
/>
</div>
)}
{/* 나머지 두 장은 아래 작게. 사진 문장(alt)이 문서에서 사라지지 않게 남긴다. */}
{shots.length > 1 && (
<ul className="mt-3 grid grid-cols-2 gap-3">
{shots.slice(1).map((image) => (
<li
key={image.mediaId}
className="relative overflow-hidden"
style={{
aspectRatio: '4 / 3',
borderRadius: 'var(--tpl-radius, 24px)',
boxShadow: '0 0 0 clamp(4px, 1vw, 8px) var(--tpl-bg, #fff)',
}}
>
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/*
판 2 — 가격 · 핵심 값.
★ 국내 숙박 사이트에서 첫 화면이 답해야 하는 건 셋이다: 얼마인가 · 언제 드나 ·
어디서 예약하나. 예약은 위 버튼이 받고 나머지 둘이 이 띠다.
값이 하나도 없으면(수집 전 가게) 빈 칸 네 개 대신 판을 지운다.
*/}
{(price || facts.length > 0) && (
<div className="w-full" style={{backgroundColor: 'var(--pastel-2)'}}>
<dl className="shell flex flex-wrap items-center justify-center gap-x-12 gap-y-5 py-8 text-center">
{price && (
<div>
<dt
className="text-[length:var(--fs-xs)] font-extrabold"
style={{color: 'var(--tpl-bg, #fff)', opacity: 0.85}}
>
</dt>
<dd
className="mt-1 font-extrabold tabular-nums"
style={{fontSize: 'var(--fs-h3)', color: 'var(--tpl-bg, #fff)'}}
>
{price}
</dd>
</div>
)}
{facts.map((row) => (
<div key={row.label}>
<dt
className="text-[length:var(--fs-xs)] font-extrabold"
style={{color: 'var(--tpl-bg, #fff)', opacity: 0.85}}
>
{row.label}
</dt>
<dd
className="mt-1 font-extrabold"
style={{fontSize: 'var(--fs-h3)', color: 'var(--tpl-bg, #fff)'}}
>
{row.value}
</dd>
</div>
))}
</dl>
</div>
)}
</section>
);
}