o2o-site-AEO/solution/site/src/layouts/boutique/Hero.tsx
Mina Choi 7a7f00dd15 [fix] solution/site: 첫 화면 태그 삭제·소문구 공통화 — 부티크 모바일도 문 모양
- 라운드·코랄·미니멀·솔숲: 히어로의 ‘주차 가능’ 칩 삭제. 일러스트: 지역 말풍선 삭제
- kit/heroSub.ts: 소문구 = 사장님 문구, 없으면 가장 가까운 명소까지 거리. 솔숲에만 있던 규칙을 9종 공통으로
- 고택·부티크: 캐치프레이즈 아래로 소문구 순서 변경
- 부티크 모바일: 전체화면 사진(664px)을 데스크톱과 같은 아치로 — 첫 화면 593px
- docs/TEMPLATE_DESIGN.md: 첫 화면 규칙

tsc·eslint 통과 · vitest 123 passed · 9종 모바일·1440px 첫 화면 캡처 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:33:17 +09:00

96 lines
3.4 KiB
TypeScript

import {useEffect, useState} from 'react';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive';
import {PlaceCategory} from '@o2o/shared';
import {heroSub} from '../kit/heroSub';
import {BOOK_EVENT} from '../paper/Rooms';
import {pad2} from './ui';
import {Weather} from './Weather';
const MAX_SLIDES = 5;
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const slides = [...payload.media]
.filter((image) => image.alt?.trim())
.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))
.slice(0, MAX_SLIDES);
const [index, setIndex] = useState(0);
const total = slides.length;
useEffect(() => {
if (total < 2) return;
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const timer = window.setInterval(() => {
if (!document.hidden && !motion.matches) setIndex((was) => (was + 1) % total);
}, 5000);
return () => window.clearInterval(timer);
}, [total]);
const sub = heroSub(payload);
const english = place.englishName?.trim();
const locality = [place.addressLocality, place.addressRegion].find(Boolean);
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
const booking = bookingLinks(payload)[0];
return (
<>
<section
className={total > 0 ? 'b-hero' : 'b-hero b-hero--plain'}
id="top"
data-pg="home"
role="region"
aria-roledescription="carousel"
aria-label={`${place.name} 사진`}
>
{slides.map((image, i) => (
<div key={image.mediaId} className={i === index ? 'b-slide on' : 'b-slide'} aria-hidden={i !== index}>
<img
src={image.url}
alt={image.alt}
fetchPriority={i === 0 ? 'high' : 'low'}
loading={i === 0 ? undefined : 'lazy'}
decoding="async"
/>
</div>
))}
<div className="b-hero-copy">
{english && <p className="b-hero-en">{english}</p>}
<h1 className={english ? 'b-hero-name' : 'b-hero-name b-hero-name--solo'}>{place.name}</h1>
{locality && <p className="b-hero-loc">{locality}</p>}
<p className="b-hero-cp">
<HeroCatchphrase />
</p>
{sub && <p className="b-hero-ko">{sub}</p>}
<div className="b-hero-act">
{stayBooking ? (
<button type="button" className="btn" onClick={() => window.dispatchEvent(new Event(BOOK_EVENT))}>
예약하기
</button>
) : (
booking && (
<a className="btn" href={booking.url} target="_blank" rel="noopener noreferrer">
{bookingActionLabel(booking)}
</a>
)
)}
<a className="btn ghost" href="#/stay">
이용안내 보기
</a>
</div>
</div>
{total > 1 && (
<p className="b-hero-count" aria-live="polite">
<span>{pad2(index + 1)}</span> &nbsp;/&nbsp; {pad2(total)}
</p>
)}
</section>
{isSectionEnabled(payload, 'weather') && (
<div data-editor-id="weather" style={{display: 'contents'}}>
<Weather />
</div>
)}
</>
);
}