import {useCallback, useEffect, useRef, useState} from 'react'; import {ChevronLeft, ChevronRight, Phone} from 'lucide-react'; import {useSite} from '@site/lib/site-context'; import {bookingActionLabel, bookingLinks, heroFacts, lowestPrice} from '@site/lib/derive'; /** * 첫 화면 — 국내 펜션 사이트의 정석(`hero.slideshow`). * * ★ 기본 히어로와 다른 건 색이 아니라 **골격**이다. * 기본형은 사진 한 장 위에 이름이 왼쪽 아래 붙는다. 국내 펜션은 예외 없이 * **화면을 꽉 채운 사진이 저절로 넘어가고**, 이름이 한가운데 서고, 그 아래 띠에서 * 값과 예약 창구가 한 번에 읽힌다. 첫 화면에서 "어떤 집인가 · 얼마인가 · 어디로 예약하나" * 셋이 끝나야 한다. * * ★ 가로 스크롤 상자로 만들지 않는다 (실측) * 처음엔 scroll-snap 트랙으로 짰는데, 첫 화면의 8할을 차지하는 그 상자 위에서 * **휠을 굴려도 페이지가 안 내려갔다.** 가로 스크롤 컨테이너가 휠을 먹는다. * 첫 화면에서 스크롤이 막히는 건 사이트가 고장 난 것과 같다. * 그래서 슬라이드를 겹쳐 두고 불투명도로 바꾼다 — 휠을 가로채는 요소가 아예 없다. * * ★ 자동으로 넘기되 손을 대면 멈춘다. 읽는 중에 사진이 바뀌면 그건 방해다. * ★ 다섯 장이 **전부 HTML 에 있다.** 한 장씩 갈아 끼우면 나머지 alt 문장이 문서에서 * 사라진다 — 이미지 검색이 그 문장을 읽는다. */ /** 자동 전환 간격. 3초는 글을 읽기 전에 바뀌고, 10초는 넘어가는 걸 아무도 못 본다. */ const ROTATE_MS = 6000; /** 다섯 장이면 충분하다. 열 장을 자동으로 돌리면 마지막 장은 아무도 못 본다. */ const MAX_SLIDES = 5; export function HeroPension() { const payload = useSite(); const {place, narrative} = payload; const facts = heroFacts(payload); const price = lowestPrice(payload); const links = bookingLinks(payload); const locality = place.addressLocality ?? place.addressRegion; // 대표 사진이 먼저 온다 — og:image 와 첫 장이 다르면 공유 카드와 화면이 어긋난다. 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 [paused, setPaused] = useState(false); const touchX = useRef(null); const step = useCallback( (delta: number) => setIndex((now) => (now + delta + slides.length) % slides.length), [slides.length], ); useEffect(() => { if (paused || slides.length < 2) return; const timer = setInterval(() => step(1), ROTATE_MS); return () => clearInterval(timer); }, [paused, step, slides.length]); return (
setPaused(true)} onMouseLeave={() => setPaused(false)} onFocusCapture={() => setPaused(true)} onBlurCapture={() => setPaused(false)} onTouchStart={(event) => { touchX.current = event.touches[0].clientX; }} onTouchEnd={(event) => { const from = touchX.current; touchX.current = null; if (from === null) return; const moved = event.changedTouches[0].clientX - from; // 40px 아래는 세로로 넘기다 손가락이 살짝 흐른 것이다. if (Math.abs(moved) > 40) step(moved < 0 ? 1 : -1); }} > {slides.map((image, i) => ( {image.alt} ))} {/* ★ 사진을 고르게 덮지 않는다 — 통째로 덮으면 팔려는 물건이 탁해진다. 가운데 글자가 놓이는 만큼만 옅게, 아래는 짙게. */}
{locality &&

{locality}

} {/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}

{place.name}

{(narrative.tagline ?? narrative.heroSubline) && (

{narrative.tagline ?? narrative.heroSubline}

)}
{slides.length > 1 && ( <> step(-1)} /> step(1)} />
    {slides.map((image, i) => (
  • ))}
)}
{/* 사진 아래 띠 — 국내 펜션 사이트가 첫 화면에서 반드시 답하는 것. ★ 사진 위에 얹지 않는다. 밝은 사진에서는 글도 사진도 죽는다. */} {(price || facts.length > 0 || links.length > 0 || place.phone) && (
{price && (
1박 최저가
{price}
)} {facts.map((row) => (
{row.label}
{row.value}
))}
{place.phone && ( {place.phone} )} {/* 확정된 예약 채널만 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다. */} {links.length > 0 && ( {bookingActionLabel(links[0])} )}
)}
); } function Arrow({dir, onClick}: {dir: 'prev' | 'next'; onClick: () => void}) { const Icon = dir === 'prev' ? ChevronLeft : ChevronRight; return ( ); }