도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
225 lines
9.6 KiB
TypeScript
225 lines
9.6 KiB
TypeScript
import {useCallback, useEffect, useRef, useState} from 'react';
|
|
import {ChevronLeft, ChevronRight, Phone} from 'lucide-react';
|
|
import {useSite} from '@/lib/site-context';
|
|
import {bookingLabel, bookingLinks, heroFacts, lowestPrice} from '@/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<number | null>(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 (
|
|
<section id="top" className="w-full">
|
|
<div
|
|
className="relative w-full overflow-hidden"
|
|
style={{
|
|
backgroundColor: 'var(--tpl-inverse, #1c1917)',
|
|
color: 'var(--tpl-bg, #ffffff)',
|
|
// 모바일 주소창이 접혔다 펴질 때 100vh 는 출렁여 글자가 뛴다 — 상·하한을 둔다.
|
|
height: 'clamp(24rem, 76vh, 42rem)',
|
|
}}
|
|
onMouseEnter={() => 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) => (
|
|
<img
|
|
key={image.mediaId}
|
|
src={image.url}
|
|
alt={image.alt}
|
|
// 첫 장은 LCP 다 — 지연 로딩하면 첫 화면이 그만큼 늦게 뜬다.
|
|
fetchPriority={i === 0 ? 'high' : 'low'}
|
|
loading={i === 0 ? undefined : 'lazy'}
|
|
decoding="async"
|
|
className="absolute inset-0 size-full object-cover object-center transition-opacity duration-700"
|
|
style={{opacity: i === index ? 1 : 0}}
|
|
/>
|
|
))}
|
|
|
|
{/*
|
|
★ 사진을 고르게 덮지 않는다 — 통째로 덮으면 팔려는 물건이 탁해진다.
|
|
가운데 글자가 놓이는 만큼만 옅게, 아래는 짙게.
|
|
*/}
|
|
<div
|
|
className="pointer-events-none absolute inset-0"
|
|
style={{
|
|
background:
|
|
'linear-gradient(to bottom, color-mix(in srgb, var(--tpl-inverse, #1c1917) 34%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 16%, transparent) 45%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 60%, transparent) 100%)',
|
|
}}
|
|
/>
|
|
|
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-6 text-center">
|
|
{locality && <p className="label opacity-85">{locality}</p>}
|
|
{/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
|
|
<h1
|
|
className="serif mt-2"
|
|
style={{
|
|
fontSize: 'var(--fs-display)',
|
|
fontWeight: 'var(--tpl-heading-weight, 700)',
|
|
lineHeight: 1.15,
|
|
}}
|
|
>
|
|
{place.name}
|
|
</h1>
|
|
{(narrative.tagline ?? narrative.heroSubline) && (
|
|
<p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-90">
|
|
{narrative.tagline ?? narrative.heroSubline}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{slides.length > 1 && (
|
|
<>
|
|
<Arrow dir="prev" onClick={() => step(-1)} />
|
|
<Arrow dir="next" onClick={() => step(1)} />
|
|
<ul className="absolute inset-x-0 bottom-5 flex items-center justify-center gap-2">
|
|
{slides.map((image, i) => (
|
|
<li key={image.mediaId}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIndex(i)}
|
|
aria-label={`${i + 1}번째 사진`}
|
|
aria-current={i === index}
|
|
className={`h-1.5 rounded-full bg-current transition-all ${
|
|
i === index ? 'w-7 opacity-90' : 'w-1.5 opacity-45 hover:opacity-70'
|
|
}`}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/*
|
|
사진 아래 띠 — 국내 펜션 사이트가 첫 화면에서 반드시 답하는 것.
|
|
★ 사진 위에 얹지 않는다. 밝은 사진에서는 글도 사진도 죽는다.
|
|
*/}
|
|
{(price || facts.length > 0 || links.length > 0 || place.phone) && (
|
|
<div
|
|
className="border-line paper w-full border-b"
|
|
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
|
|
>
|
|
<div className="shell flex flex-col items-center gap-4 py-4 lg:flex-row lg:justify-between lg:gap-8">
|
|
<dl className="divide-line flex flex-wrap justify-center gap-y-2 sm:divide-x">
|
|
{price && (
|
|
<div className="px-5 text-center sm:first:pl-0">
|
|
<dt className="label">1박 최저가</dt>
|
|
<dd className="text-[length:var(--fs-lead)] font-bold tabular-nums">{price}</dd>
|
|
</div>
|
|
)}
|
|
{facts.map((row) => (
|
|
<div key={row.label} className="px-5 text-center">
|
|
<dt className="label">{row.label}</dt>
|
|
<dd className="text-[length:var(--fs-lead)] font-bold">{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
|
|
<div className="flex shrink-0 gap-2">
|
|
{place.phone && (
|
|
<a
|
|
href={`tel:${place.phone}`}
|
|
className="tap border-line tpl-border inline-flex items-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
|
|
>
|
|
<Phone className="size-4" />
|
|
<span>{place.phone}</span>
|
|
</a>
|
|
)}
|
|
{/* 확정된 예약 채널만 — 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다. */}
|
|
{links.length > 0 && (
|
|
<a
|
|
href={links[0].url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
|
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
|
>
|
|
{bookingLabel(links[0])} 예약
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Arrow({dir, onClick}: {dir: 'prev' | 'next'; onClick: () => void}) {
|
|
const Icon = dir === 'prev' ? ChevronLeft : ChevronRight;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={dir === 'prev' ? '이전 사진' : '다음 사진'}
|
|
className={`tap absolute top-1/2 hidden -translate-y-1/2 items-center justify-center rounded-full bg-current/15 px-3 backdrop-blur-sm transition-colors hover:bg-current/25 sm:inline-flex ${
|
|
dir === 'prev' ? 'left-4' : 'right-4'
|
|
}`}
|
|
>
|
|
<Icon className="size-5" />
|
|
</button>
|
|
);
|
|
}
|