[feat] solution/site: "고택" 템플릿 레이아웃 신설 — stay2 DOM 이식 1차

기존 template-look 시스템(색·서체 토큰)만으로는 stay2 실물과 구조 자체가 달랐다.
reservation/oasi/studio/pastel/editorial 다섯 레이아웃과 같은 자리(Shell·Hero·Rooms·
SectionHead)에 paper 를 여섯 번째로 추가 — layoutOf(templateId) 로 갈아 끼운다.

- layouts/paper/{Shell,Hero,Rooms,SectionHead}.tsx 신설
  Shell: 세리프 워드마크+미니플레이어(SongPlayer)+전화, 앵커 탭 헤더, 얇은 텍스트 푸터
  Hero: 풀블리드 회전 캐러셀(HeroPension 뼈대) + 왼쪽 정렬 카피, 가격띠 없음
  Rooms: 유닛마다 전체폭 판 + 캐러셀(카드 격자 아님)
- lib/layout.ts: LayoutId 에 'paper', stay/restaurant/cafe-paper → paper 매핑
- App.tsx·HeroSection.tsx·UnitsSection.tsx·lib/ui/Section.tsx: 기존 5분기에 paper 추가

★ 실물 대조 결과 아직 stay2 와 다르다(사장님 지적) — 탭 개수(4개 vs 지금 섹션 수만큼),
  히어로가 여백 있는 박스가 아니라 풀블리드, 객실 카드가 2열 나란히가 아니라 전체폭
  세로 배치, 영문 눈썹 라벨 없음. 다음 커밋에서 이어서 맞춘다.

★ 배포 함정 실측: prerender.ts 의 "성공한 버전은 불변" 규칙 때문에, 같은 site_version 은
  렌더러 코드를 바꿔도 재빌드 때 옛 HTML 을 그대로 재사용한다 — 버전 캐시를 지워야
  강제로 다시 구워진다(운영 영향은 별도 확인 필요).
This commit is contained in:
Mina Choi 2026-09-23 13:18:21 +09:00
parent b7b8cb856c
commit a74f7918c6
10 changed files with 551 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import {Shell as OasiShell} from '@site/layouts/oasi/Shell';
import {Shell as StudioShell} from '@site/layouts/studio/Shell';
import {Shell as PastelShell} from '@site/layouts/pastel/Shell';
import {Shell as EditorialShell} from '@site/layouts/editorial/Shell';
import {Shell as PaperShell} from '@site/layouts/paper/Shell';
import {HomePage} from '@site/pages';
/**
@ -39,6 +40,8 @@ export function App({payload}: {payload: SitePayload}) {
? PastelShell
: layout === 'editorial'
? EditorialShell
: layout === 'paper'
? PaperShell
: DefaultShell;
return (

View File

@ -0,0 +1,124 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import {useSite} from '@site/lib/site-context';
const ROTATE_MS = 6000;
const MAX_SLIDES = 5;
export function Hero() {
const payload = useSite();
const {place, narrative} = payload;
const locality = place.addressLocality ?? place.addressRegion;
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={{height: 'clamp(24rem, 74vh, 40rem)'}}
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;
if (Math.abs(moved) > 40) step(moved < 0 ? 1 : -1);
}}
>
{slides.map((image, i) => (
<img
key={image.mediaId}
src={image.url}
alt={image.alt}
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 top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 42%, transparent) 0%, color-mix(in srgb, var(--tpl-inverse, #1c1917) 10%, transparent) 40%, transparent 70%)',
}}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-col items-start gap-2 px-6 pb-10 sm:px-10 sm:pb-14">
{locality && (
<p
className="text-[length:var(--fs-xs)] uppercase opacity-100"
style={{letterSpacing: '0.28em', color: 'var(--tpl-bg, #fff)'}}
>
{locality}
</p>
)}
<h1
className="serif"
style={{
fontSize: 'var(--fs-display)',
fontWeight: 'var(--tpl-heading-weight, 400)',
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
lineHeight: 1.3,
color: 'var(--tpl-bg, #fff)',
}}
>
{place.name}
</h1>
{(narrative.tagline ?? narrative.heroSubline) && (
<p
className="measure text-[length:var(--fs-lead)] leading-relaxed opacity-100"
style={{color: 'var(--tpl-bg, #fff)'}}
>
<HeroCatchphrase>{narrative.tagline ?? narrative.heroSubline}</HeroCatchphrase>
</p>
)}
</div>
{slides.length > 1 && (
<ul className="absolute inset-x-0 bottom-5 flex items-center justify-center gap-2 sm:hidden">
{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 transition-all ${i === index ? 'w-7' : 'w-1.5'}`}
style={{backgroundColor: 'var(--tpl-bg, #fff)', opacity: i === index ? 1 : 0.5}}
/>
</li>
))}
</ul>
)}
</div>
</section>
);
}

View File

@ -0,0 +1,139 @@
import {Phone} from 'lucide-react';
import type {ChannelLink} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews, type UnitView} from '@site/lib/derive';
import {Carousel, CarouselSlide} from '@site/lib/ui';
import {SectionHead} from './SectionHead';
export function Rooms() {
const payload = useSite();
const units = unitViews(payload);
const spec = unitSpec(payload);
const booking = bookingLinks(payload)[0];
if (units.length === 0) return null;
return (
<section
id="units"
aria-labelledby="units-heading"
className="border-line w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #fff)', paddingBlock: 'var(--section-space)'}}
>
<div className="shell">
<SectionHead
id="units"
title={sectionName(payload, spec.path, `${spec.label} ${units.length}개 안내`)}
/>
</div>
<div className="flex flex-col">
{units.map((unit, i) => (
<UnitPanel key={unit.unitId} unit={unit} no={i + 1} booking={booking} phone={payload.place.phone} />
))}
</div>
</section>
);
}
function UnitPanel({
unit,
no,
booking,
phone,
}: {
unit: UnitView;
no: number;
booking?: ChannelLink;
phone?: string;
}) {
return (
<article className="border-line border-t">
{unit.images.length > 0 && (
<Carousel label={`${unit.name} 사진`} align="center" gap={0} arrows="overlay">
{unit.images.map((image) => (
<CarouselSlide key={image.mediaId} basis="basis-full">
<div className="relative aspect-3/2 overflow-hidden">
<img
src={image.url}
alt={image.alt}
loading="lazy"
decoding="async"
className="size-full object-cover"
/>
</div>
</CarouselSlide>
))}
</Carousel>
)}
<div className="shell flex flex-col gap-3 py-6">
<p
className="text-[length:var(--fs-xs)] uppercase opacity-70"
style={{letterSpacing: '0.24em'}}
>
{String(no).padStart(2, '0')}
</p>
<h3
className="serif"
style={{
fontSize: 'clamp(1.1rem, 2.4vw, 1.4rem)',
fontWeight: 'var(--tpl-heading-weight, 400)',
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
}}
>
{unit.name}
</h3>
{unit.intro && (
<p className="text-[length:var(--fs-sm)] leading-relaxed opacity-100">{unit.intro}</p>
)}
{unit.rows.length > 0 && (
<dl className="border-line divide-line mt-2 divide-y border-t">
{unit.rows.map((row) => (
<div key={row.label} className="flex items-baseline justify-between gap-4 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>
)}
{unit.chips.length > 0 && (
<ul className="mt-1 flex flex-wrap gap-1.5">
{unit.chips.map((chip) => (
<li
key={chip.label}
className="border-line inline-flex items-center rounded-full border px-3 py-1 text-[length:var(--fs-xs)]"
>
{chip.value}
</li>
))}
</ul>
)}
{(booking || phone) && (
<div className="mt-2 flex flex-wrap items-center gap-x-6 gap-y-1 text-[length:var(--fs-sm)] font-semibold">
{booking && (
<a
href={booking.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center underline underline-offset-4"
>
{bookingActionLabel(booking)}
</a>
)}
{phone && (
<a href={`tel:${phone}`} className="tap inline-flex items-center gap-1.5 underline underline-offset-4">
<Phone className="size-4" />
<span>{phone}</span>
</a>
)}
</div>
)}
</div>
</article>
);
}

View File

@ -0,0 +1,38 @@
import type {ReactNode} from 'react';
export function SectionHead({
title,
lead,
aside,
}: {
id: string;
title: string;
lead?: string;
aside?: ReactNode;
}) {
return (
<header className="mb-6 sm:mb-8">
<div aria-hidden className="border-line mb-4 w-full border-t" />
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h2
className="serif"
style={{
fontSize: 'clamp(1.15rem, 2.4vw, 1.5rem)',
fontWeight: 'var(--tpl-heading-weight, 400)',
letterSpacing: 'var(--tpl-heading-tracking, 0.03em)',
}}
>
{title}
</h2>
{lead && (
<p className="text-muted measure mt-2 text-[length:var(--fs-sm)] leading-relaxed">
{lead}
</p>
)}
</div>
{aside && <div className="shrink-0">{aside}</div>}
</div>
</header>
);
}

View File

@ -0,0 +1,230 @@
import {useEffect, useState, type ReactNode} from 'react';
import {Phone} from 'lucide-react';
import {useSite} from '@site/lib/site-context';
import {enabledSections} from '@site/lib/derive';
import {isoDate} from '@site/lib/format';
import {MobileTabBar} from '@site/sections/MobileTabBar';
import {SongPlayer} from '@site/sections/SongPlayer';
interface TabEntry {
label: string;
anchor: string;
}
const ANCHOR: Record<string, string> = {
intro: 'about',
info: 'info',
rooms: 'units',
menu: 'units',
programs: 'units',
pricing: 'pricing',
booking: 'booking',
space: 'space',
inquiry: 'inquiry',
exhibition: 'exhibition',
photos: 'gallery',
local: 'guide',
weather: 'weather',
map: 'location',
faq: 'faq',
songs: 'songs',
daily: 'daily',
chronicle: 'chronicle',
reading: 'reading',
people: 'people',
quiz: 'quiz',
postcard: 'postcard',
video: 'video',
itinerary: 'itinerary',
};
const FALLBACK_LABEL: Record<string, string> = {
about: '소개',
info: '이용 정보',
units: '객실',
pricing: '요금',
booking: '예약 안내',
space: '공간',
inquiry: '문의',
exhibition: '관람 안내',
gallery: '사진',
guide: '주변',
weather: '날씨',
location: '오시는 길',
faq: '자주 묻는 질문',
songs: '노래',
daily: '일력',
chronicle: '연표',
reading: '읽기',
people: '인물',
quiz: '퀴즈',
postcard: '엽서',
video: '영상',
itinerary: '일정',
};
function useTabEntries(): TabEntry[] {
const payload = useSite();
const seen = new Set<string>();
const entries: TabEntry[] = [];
for (const section of enabledSections(payload)) {
const anchor = ANCHOR[section.id];
if (!anchor || seen.has(anchor)) continue;
if (anchor === 'units' && payload.units.length === 0) continue;
seen.add(anchor);
entries.push({label: section.name?.trim() || FALLBACK_LABEL[anchor] || anchor, anchor});
}
if (!seen.has('location')) entries.push({label: '오시는 길', anchor: 'location'});
return entries;
}
function useActiveAnchor(entries: TabEntry[]): string {
const anchors = entries.map((entry) => entry.anchor).join(',');
const [active, setActive] = useState('');
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') return;
const targets = anchors
.split(',')
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(records) => {
const hit = records.find((record) => record.isIntersecting);
if (hit) setActive(hit.target.id);
},
{rootMargin: '-45% 0px -50% 0px'},
);
targets.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [anchors]);
return active;
}
export function Shell({children}: {children: ReactNode}) {
const payload = useSite();
const {place, site} = payload;
const entries = useTabEntries();
const active = useActiveAnchor(entries);
const address = place.roadAddress ?? place.address;
return (
<div
className="flex min-h-screen w-full flex-col"
style={{backgroundColor: 'var(--tpl-bg, #fff)', color: 'var(--tpl-text, #1a1a1a)'}}
>
<header
className="border-line safe-t sticky top-0 z-40 w-full border-b backdrop-blur-md"
style={{backgroundColor: 'color-mix(in srgb, var(--tpl-surface, #fff) 90%, transparent)'}}
>
<div className="shell flex h-14 items-center justify-between gap-3">
<a
href="#top"
className="serif min-w-0 truncate text-[length:var(--fs-lead)]"
style={{fontWeight: 'var(--tpl-heading-weight, 400)'}}
>
{place.name}
</a>
<div className="flex shrink-0 items-center gap-1.5">
<SongPlayer />
{place.phone && (
<a
href={`tel:${place.phone}`}
className="tap flex size-9 items-center justify-center rounded-full"
aria-label="전화 걸기"
>
<Phone className="size-4" />
</a>
)}
</div>
</div>
{entries.length > 0 && (
<nav aria-label="주요 메뉴" className="border-line border-t">
<ul className="shell flex gap-5 overflow-x-auto">
{entries.map((entry) => {
const on = entry.anchor === active;
return (
<li key={entry.anchor} className="shrink-0">
<a
href={`#${entry.anchor}`}
aria-current={on ? 'true' : undefined}
className="tap flex h-11 items-center whitespace-nowrap text-[length:var(--fs-sm)]"
style={{
fontWeight: on ? 700 : 400,
boxShadow: on ? 'inset 0 -2px 0 currentColor' : undefined,
}}
>
{entry.label}
</a>
</li>
);
})}
</ul>
</nav>
)}
</header>
<div className="flex w-full flex-1 flex-col">{children}</div>
<footer
className="w-full pb-28 pt-12 md:pb-14"
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
>
<div className="shell flex flex-col gap-4 text-[length:var(--fs-sm)]">
<p className="serif" style={{fontSize: 'var(--fs-lead)', fontWeight: 'var(--tpl-heading-weight, 400)'}}>
{place.name}
</p>
{address && <p className="opacity-100">{address}</p>}
{place.phone && (
<p>
<a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">
{place.phone}
</a>
</p>
)}
<div className="border-line flex flex-wrap items-center gap-x-3 gap-y-1 border-t pt-4 text-[length:var(--fs-xs)] opacity-100">
<span>: {place.name}</span>
{place.legal?.representative && <span>: {place.legal.representative}</span>}
{place.legal?.businessRegistrationNumber && (
<span>: {place.legal.businessRegistrationNumber}</span>
)}
{place.legal?.mailOrderNumber && <span>: {place.legal.mailOrderNumber}</span>}
{place.legal?.licenseNumber && (
<span>
{place.legal.licenseLabel ?? '인허가번호'}: {place.legal.licenseNumber}
</span>
)}
</div>
<p className="byline text-[length:var(--fs-xs)] opacity-100">
<span className="author">· {place.name}</span>
{' · 최종 업데이트: '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
</p>
<p className="text-[length:var(--fs-xs)] opacity-100">
<a
href="https://www.o2osolution.ai/"
target="_blank"
rel="noopener noreferrer"
className="underline-offset-2 hover:underline"
>
AI O2O
</a>
Web4Ai로 .
</p>
</div>
</footer>
<MobileTabBar />
</div>
);
}

View File

@ -0,0 +1,4 @@
export {Shell} from './Shell';
export {SectionHead} from './SectionHead';
export {Hero} from './Hero';
export {Rooms} from './Rooms';

View File

@ -13,7 +13,7 @@ import {useSite} from '@site/lib/site-context';
* ( `site_service.set_template`).
* . `default` .
*/
export type LayoutId = 'default' | 'reservation' | 'oasi' | 'studio' | 'pastel' | 'editorial';
export type LayoutId = 'default' | 'reservation' | 'oasi' | 'studio' | 'pastel' | 'editorial' | 'paper';
const LAYOUT_BY_TEMPLATE: Record<string, LayoutId> = {
'stay-reservation': 'reservation',
@ -21,6 +21,9 @@ const LAYOUT_BY_TEMPLATE: Record<string, LayoutId> = {
'stay-studio': 'studio',
'stay-pastel': 'pastel',
'stay-editorial': 'editorial',
'stay-paper': 'paper',
'restaurant-paper': 'paper',
'cafe-paper': 'paper',
};
export function layoutOf(templateId: string | undefined): LayoutId {

View File

@ -20,6 +20,7 @@ import {SectionHead as OasiHead} from '@site/layouts/oasi/SectionHead';
import {SectionHead as StudioHead} from '@site/layouts/studio/SectionHead';
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
import {SectionHead as EditorialHead} from '@site/layouts/editorial/SectionHead';
import {SectionHead as PaperHead} from '@site/layouts/paper/SectionHead';
export type SectionTone = 'base' | 'alt' | 'dark';
@ -72,6 +73,8 @@ export function Section({
? PastelHead
: layout === 'editorial'
? EditorialHead
: layout === 'paper'
? PaperHead
: DefaultHead;
return (

View File

@ -11,6 +11,7 @@ import {Hero as OasiHero} from '@site/layouts/oasi/Hero';
import {Hero as StudioHero} from '@site/layouts/studio/Hero';
import {Hero as PastelHero} from '@site/layouts/pastel/Hero';
import {Hero as EditorialHero} from '@site/layouts/editorial/Hero';
import {Hero as PaperHero} from '@site/layouts/paper/Hero';
/**
* .
@ -33,6 +34,7 @@ export function HeroSection() {
if (layout === 'studio') return <StudioHero />;
if (layout === 'pastel') return <PastelHero />;
if (layout === 'editorial') return <EditorialHero />;
if (layout === 'paper') return <PaperHero />;
const heroVariant = payload.theme.sections.find((s) => s.id === 'hero')?.variantId;
if (heroVariant === 'hero.slideshow') return <HeroPension />;

View File

@ -13,6 +13,7 @@ import {Rooms as OasiRooms} from '@site/layouts/oasi/Rooms';
import {Rooms as StudioRooms} from '@site/layouts/studio/Rooms';
import {Rooms as PastelRooms} from '@site/layouts/pastel/Rooms';
import {Rooms as EditorialRooms} from '@site/layouts/editorial/Rooms';
import {Rooms as PaperRooms} from '@site/layouts/paper/Rooms';
/**
* · · .
@ -41,6 +42,7 @@ export function UnitsSection() {
if (layout === 'studio') return <StudioRooms />;
if (layout === 'pastel') return <PastelRooms />;
if (layout === 'editorial') return <EditorialRooms />;
if (layout === 'paper') return <PaperRooms />;
const roomsVariant = payload.theme.sections.find((s) => s.id === spec.path)?.variantId;
if (roomsVariant === 'rooms.bands') return <UnitsBands />;