[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:
parent
b7b8cb856c
commit
a74f7918c6
@ -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 StudioShell} from '@site/layouts/studio/Shell';
|
||||||
import {Shell as PastelShell} from '@site/layouts/pastel/Shell';
|
import {Shell as PastelShell} from '@site/layouts/pastel/Shell';
|
||||||
import {Shell as EditorialShell} from '@site/layouts/editorial/Shell';
|
import {Shell as EditorialShell} from '@site/layouts/editorial/Shell';
|
||||||
|
import {Shell as PaperShell} from '@site/layouts/paper/Shell';
|
||||||
import {HomePage} from '@site/pages';
|
import {HomePage} from '@site/pages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -39,7 +40,9 @@ export function App({payload}: {payload: SitePayload}) {
|
|||||||
? PastelShell
|
? PastelShell
|
||||||
: layout === 'editorial'
|
: layout === 'editorial'
|
||||||
? EditorialShell
|
? EditorialShell
|
||||||
: DefaultShell;
|
: layout === 'paper'
|
||||||
|
? PaperShell
|
||||||
|
: DefaultShell;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SiteProvider payload={payload}>
|
<SiteProvider payload={payload}>
|
||||||
|
|||||||
124
solution/site/src/layouts/paper/Hero.tsx
Normal file
124
solution/site/src/layouts/paper/Hero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
solution/site/src/layouts/paper/Rooms.tsx
Normal file
139
solution/site/src/layouts/paper/Rooms.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
solution/site/src/layouts/paper/SectionHead.tsx
Normal file
38
solution/site/src/layouts/paper/SectionHead.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
solution/site/src/layouts/paper/Shell.tsx
Normal file
230
solution/site/src/layouts/paper/Shell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
solution/site/src/layouts/paper/index.ts
Normal file
4
solution/site/src/layouts/paper/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export {Shell} from './Shell';
|
||||||
|
export {SectionHead} from './SectionHead';
|
||||||
|
export {Hero} from './Hero';
|
||||||
|
export {Rooms} from './Rooms';
|
||||||
@ -13,7 +13,7 @@ import {useSite} from '@site/lib/site-context';
|
|||||||
* (백엔드도 길이만 검사한다 — `site_service.set_template`). 계약을 넓히지 않고
|
* (백엔드도 길이만 검사한다 — `site_service.set_template`). 계약을 넓히지 않고
|
||||||
* 있는 값에 뜻을 준다. 모르는 값이 오면 `default` 로 떨어져 화면이 깨지지 않는다.
|
* 있는 값에 뜻을 준다. 모르는 값이 오면 `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> = {
|
const LAYOUT_BY_TEMPLATE: Record<string, LayoutId> = {
|
||||||
'stay-reservation': 'reservation',
|
'stay-reservation': 'reservation',
|
||||||
@ -21,6 +21,9 @@ const LAYOUT_BY_TEMPLATE: Record<string, LayoutId> = {
|
|||||||
'stay-studio': 'studio',
|
'stay-studio': 'studio',
|
||||||
'stay-pastel': 'pastel',
|
'stay-pastel': 'pastel',
|
||||||
'stay-editorial': 'editorial',
|
'stay-editorial': 'editorial',
|
||||||
|
'stay-paper': 'paper',
|
||||||
|
'restaurant-paper': 'paper',
|
||||||
|
'cafe-paper': 'paper',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function layoutOf(templateId: string | undefined): LayoutId {
|
export function layoutOf(templateId: string | undefined): LayoutId {
|
||||||
|
|||||||
@ -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 StudioHead} from '@site/layouts/studio/SectionHead';
|
||||||
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
|
import {SectionHead as PastelHead} from '@site/layouts/pastel/SectionHead';
|
||||||
import {SectionHead as EditorialHead} from '@site/layouts/editorial/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';
|
export type SectionTone = 'base' | 'alt' | 'dark';
|
||||||
|
|
||||||
@ -72,7 +73,9 @@ export function Section({
|
|||||||
? PastelHead
|
? PastelHead
|
||||||
: layout === 'editorial'
|
: layout === 'editorial'
|
||||||
? EditorialHead
|
? EditorialHead
|
||||||
: DefaultHead;
|
: layout === 'paper'
|
||||||
|
? PaperHead
|
||||||
|
: DefaultHead;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|||||||
@ -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 StudioHero} from '@site/layouts/studio/Hero';
|
||||||
import {Hero as PastelHero} from '@site/layouts/pastel/Hero';
|
import {Hero as PastelHero} from '@site/layouts/pastel/Hero';
|
||||||
import {Hero as EditorialHero} from '@site/layouts/editorial/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 === 'studio') return <StudioHero />;
|
||||||
if (layout === 'pastel') return <PastelHero />;
|
if (layout === 'pastel') return <PastelHero />;
|
||||||
if (layout === 'editorial') return <EditorialHero />;
|
if (layout === 'editorial') return <EditorialHero />;
|
||||||
|
if (layout === 'paper') return <PaperHero />;
|
||||||
|
|
||||||
const heroVariant = payload.theme.sections.find((s) => s.id === 'hero')?.variantId;
|
const heroVariant = payload.theme.sections.find((s) => s.id === 'hero')?.variantId;
|
||||||
if (heroVariant === 'hero.slideshow') return <HeroPension />;
|
if (heroVariant === 'hero.slideshow') return <HeroPension />;
|
||||||
|
|||||||
@ -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 StudioRooms} from '@site/layouts/studio/Rooms';
|
||||||
import {Rooms as PastelRooms} from '@site/layouts/pastel/Rooms';
|
import {Rooms as PastelRooms} from '@site/layouts/pastel/Rooms';
|
||||||
import {Rooms as EditorialRooms} from '@site/layouts/editorial/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 === 'studio') return <StudioRooms />;
|
||||||
if (layout === 'pastel') return <PastelRooms />;
|
if (layout === 'pastel') return <PastelRooms />;
|
||||||
if (layout === 'editorial') return <EditorialRooms />;
|
if (layout === 'editorial') return <EditorialRooms />;
|
||||||
|
if (layout === 'paper') return <PaperRooms />;
|
||||||
|
|
||||||
const roomsVariant = payload.theme.sections.find((s) => s.id === spec.path)?.variantId;
|
const roomsVariant = payload.theme.sections.find((s) => s.id === spec.path)?.variantId;
|
||||||
if (roomsVariant === 'rooms.bands') return <UnitsBands />;
|
if (roomsVariant === 'rooms.bands') return <UnitsBands />;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user