사장님이 고를 수 있는 숙박 템플릿이 고택 하나뿐이었다. 모두 stay2 콘텐츠(날씨·미니 블로그· 지역 이야기·객실·축제·일정·후기·엽서·노래)를 그대로 싣고 모양만 다르다. - layouts/<id>/: 템플릿별 Frame·섹션·CSS. 데스크톱은 넓은 배치(1024px 이상), 모바일 우선 - layouts/kit/kit.css: 탭 전환·시트·예약 폼·접기 등 공통 구조 - shared templates.json · builder.ts · catalog.ts: 템플릿·LayoutId 등록 - seo/head.ts: 템플릿별 한글 웹폰트 — 서로 겹치지 않게 배정 - paper Frame·Intro: 같은 탭을 다시 눌러도 맨 위로, 미니 블로그를 글 여러 개로 - docs/TEMPLATES.md · TEMPLATE_DESIGN.md: 목록과 화면 규칙(간격·접기·✓ 표시·글꼴) eslint·tsc 통과, vitest 123 passed, 템플릿 9개 굽기 성공 · 간격 검사 0건
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
import {useEffect, useRef, useState, type ReactNode, type RefObject} from 'react';
|
|
import {PlaceCategory, selectPublishable} from '@o2o/shared';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {
|
|
bookingActionLabel,
|
|
bookingLinks,
|
|
channelLabel,
|
|
isSectionEnabled,
|
|
publicLinks,
|
|
stayBookingView,
|
|
} from '@site/lib/derive';
|
|
import {isoDate, naverDirectionsUrl} from '@site/lib/format';
|
|
import {useScrollLock} from '@site/lib/ui/use-scroll-lock';
|
|
import {SongPlayer} from '@site/sections/SongPlayer';
|
|
import {BookingForm} from '../paper/BookingForm';
|
|
import {BOOK_EVENT} from '../paper/Rooms';
|
|
import {Sheet} from '../paper/Sheet';
|
|
import {ChatIcon, HOUSE_WORD, pad2, PhoneIcon, PinIcon, Wordmark} from './ui';
|
|
import '../kit/kit.css';
|
|
import './boutique.css';
|
|
|
|
type PageId = 'home' | 'area' | 'stay' | 'story';
|
|
|
|
const PAGE_HEAD: Record<Exclude<PageId, 'home'>, {en: string; title: string; note: string}> = {
|
|
area: {
|
|
en: 'Around',
|
|
title: '지역 소개',
|
|
note: '대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기입니다.',
|
|
},
|
|
stay: {
|
|
en: 'Stay',
|
|
title: '이용안내 · 예약',
|
|
note: '객실 상세와 요금, 예약하는 방법입니다.',
|
|
},
|
|
story: {
|
|
en: 'Story',
|
|
title: '이야기',
|
|
note: '사장님이 남기는 글과 다녀가신 분들의 후기, 그리고 엽서 한 장입니다.',
|
|
},
|
|
};
|
|
|
|
const PAGE_EN: Record<PageId, string> = {home: 'The House', area: 'Around', stay: 'Stay', story: 'Story'};
|
|
|
|
function pageOf(hash: string): PageId {
|
|
const id = hash.replace(/^#\/?/, '');
|
|
return id === 'area' || id === 'stay' || id === 'story' ? id : 'home';
|
|
}
|
|
|
|
function usePage(): PageId {
|
|
const [page, setPage] = useState<PageId>('home');
|
|
useEffect(() => {
|
|
const sync = () => {
|
|
const {hash} = window.location;
|
|
if (hash && !hash.startsWith('#/')) return;
|
|
setPage(pageOf(hash));
|
|
window.scrollTo({top: 0, behavior: 'instant'});
|
|
};
|
|
sync();
|
|
window.addEventListener('hashchange', sync);
|
|
const again = (event: MouseEvent) => {
|
|
const link = event.target instanceof Element ? event.target.closest('a[href^="#/"]') : null;
|
|
const href = link?.getAttribute('href');
|
|
if (href && pageOf(href) === pageOf(window.location.hash)) window.scrollTo({top: 0, behavior: 'instant'});
|
|
};
|
|
document.addEventListener('click', again);
|
|
return () => {
|
|
window.removeEventListener('hashchange', sync);
|
|
document.removeEventListener('click', again);
|
|
};
|
|
}, []);
|
|
return page;
|
|
}
|
|
|
|
function useSolid(page: PageId): boolean {
|
|
const [scrolled, setScrolled] = useState(false);
|
|
useEffect(() => {
|
|
const sync = () => setScrolled(window.scrollY > window.innerHeight * 0.8);
|
|
sync();
|
|
window.addEventListener('scroll', sync, {passive: true});
|
|
return () => window.removeEventListener('scroll', sync);
|
|
}, []);
|
|
return page !== 'home' || scrolled;
|
|
}
|
|
|
|
function useReveal(root: RefObject<HTMLDivElement | null>, page: PageId) {
|
|
useEffect(() => {
|
|
const el = root.current;
|
|
if (!el || !('IntersectionObserver' in window)) return;
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
const seen = new WeakSet<Element>();
|
|
const io = new IntersectionObserver(
|
|
(entries) =>
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) return;
|
|
entry.target.classList.add('show');
|
|
io.unobserve(entry.target);
|
|
}),
|
|
{threshold: 0.12},
|
|
);
|
|
const scan = () => {
|
|
el.querySelectorAll('.rv:not(.show)').forEach((node) => {
|
|
if (seen.has(node)) return;
|
|
seen.add(node);
|
|
const box = node.getBoundingClientRect();
|
|
if (box.height > 0 && box.top < window.innerHeight && box.bottom > 0) node.classList.add('show');
|
|
else io.observe(node);
|
|
});
|
|
};
|
|
scan();
|
|
el.classList.add('rv-on');
|
|
const mo = new MutationObserver(scan);
|
|
mo.observe(el, {childList: true, subtree: true});
|
|
return () => {
|
|
mo.disconnect();
|
|
io.disconnect();
|
|
};
|
|
}, [root, page]);
|
|
}
|
|
|
|
function useTabs() {
|
|
const payload = useSite();
|
|
const tabs: {label: string; page: PageId}[] = [
|
|
{label: `${HOUSE_WORD[payload.place.category] ?? '가게'} 소개`, page: 'home'},
|
|
];
|
|
if (isSectionEnabled(payload, 'local')) tabs.push({label: '지역 소개', page: 'area'});
|
|
tabs.push({label: '이용안내 · 예약', page: 'stay'});
|
|
tabs.push({label: '이야기', page: 'story'});
|
|
return tabs;
|
|
}
|
|
|
|
export function Frame({children}: {children: ReactNode}) {
|
|
const payload = useSite();
|
|
const {place, site} = payload;
|
|
const tabs = useTabs();
|
|
const page = usePage();
|
|
const solid = useSolid(page);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [bookingOpen, setBookingOpen] = useState(false);
|
|
useReveal(rootRef, page);
|
|
useScrollLock(menuOpen);
|
|
useEffect(() => {
|
|
const open = () => setBookingOpen(true);
|
|
window.addEventListener(BOOK_EVENT, open);
|
|
return () => window.removeEventListener(BOOK_EVENT, open);
|
|
}, []);
|
|
useEffect(() => setMenuOpen(false), [page]);
|
|
useEffect(() => {
|
|
if (!menuOpen) return;
|
|
const onKey = (event: KeyboardEvent) => event.key === 'Escape' && setMenuOpen(false);
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [menuOpen]);
|
|
|
|
const booking = bookingLinks(payload)[0];
|
|
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
|
const kakao = payload.links.find((link) => link.confirmed && /kakao/i.test(link.url));
|
|
const address = place.roadAddress ?? place.address;
|
|
const facts = selectPublishable(payload.facts);
|
|
const fact = (key: string) => facts.find((f) => f.key === key)?.value ?? undefined;
|
|
const hours = [
|
|
fact('check_in_time') && `체크인 ${fact('check_in_time')}`,
|
|
fact('check_out_time') && `체크아웃 ${fact('check_out_time')}`,
|
|
fact('smoking') === 'false' && '전 구역 금연',
|
|
].filter(Boolean);
|
|
const channels = publicLinks(payload);
|
|
const instagram = channels.find((link) => /instagram\.com/i.test(link.url));
|
|
const areaPills = (
|
|
[
|
|
['주변 명소', '#walk', payload.local.attractions.length > 0],
|
|
['주변 맛집', '#eat', payload.local.restaurants.length > 0],
|
|
['엽서', '#/story', true],
|
|
['축제', '#festival', isSectionEnabled(payload, 'festival') && payload.local.festivals.length > 0],
|
|
['일정', '#itinerary', isSectionEnabled(payload, 'itinerary')],
|
|
['이야기', '#story', isSectionEnabled(payload, 'story')],
|
|
['날씨', '#/', isSectionEnabled(payload, 'weather')],
|
|
] as [string, string, boolean][]
|
|
).filter(([, , on]) => on);
|
|
const locality = place.addressLocality ?? place.addressRegion;
|
|
const english = place.englishName?.trim();
|
|
|
|
return (
|
|
<div className="w4-boutique" data-page={page} ref={rootRef}>
|
|
<header className={solid ? 'b-hd solid' : 'b-hd'}>
|
|
<a className={english ? 'b-logo' : 'b-logo b-logo--ko'} href="#/">
|
|
{english || place.name}
|
|
{locality && <small>{locality}</small>}
|
|
</a>
|
|
<nav className="b-hnav" aria-label="페이지">
|
|
{tabs.map((tab) => (
|
|
<a
|
|
key={tab.page}
|
|
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
|
|
className={tab.page === page ? 'on' : undefined}
|
|
aria-current={tab.page === page ? 'page' : undefined}
|
|
>
|
|
{tab.label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
<div className="b-hcta">
|
|
{place.phone && (
|
|
<a href={`tel:${place.phone}`}>
|
|
<PhoneIcon />
|
|
{place.phone}
|
|
</a>
|
|
)}
|
|
{stayBooking ? (
|
|
<button type="button" className="cta" onClick={() => setBookingOpen(true)}>
|
|
예약하기
|
|
</button>
|
|
) : (
|
|
booking && (
|
|
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
|
|
{bookingActionLabel(booking)}
|
|
</a>
|
|
)
|
|
)}
|
|
</div>
|
|
<div className="b-hbtn">
|
|
{place.phone && (
|
|
<a className="b-circ" href={`tel:${place.phone}`} aria-label="전화 걸기">
|
|
CALL
|
|
</a>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="b-circ"
|
|
aria-expanded={menuOpen}
|
|
aria-controls="b-menu"
|
|
onClick={() => setMenuOpen((was) => !was)}
|
|
>
|
|
{menuOpen ? 'CLOSE' : 'MENU'}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<nav className="b-menu" id="b-menu" aria-label="주요 메뉴" hidden={!menuOpen}>
|
|
{tabs.map((tab, i) => (
|
|
<a
|
|
key={tab.page}
|
|
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
|
|
className={tab.page === page ? 'on' : undefined}
|
|
aria-current={tab.page === page ? 'page' : undefined}
|
|
onClick={() => setMenuOpen(false)}
|
|
>
|
|
<span className="b-menu-no">{pad2(i + 1)}</span>
|
|
<span className="b-menu-en">{PAGE_EN[tab.page]}</span>
|
|
<span className="b-menu-ko">{tab.label}</span>
|
|
</a>
|
|
))}
|
|
</nav>
|
|
|
|
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
|
|
<div key={id} data-pg={id}>
|
|
<section className="b-phead">
|
|
<p className="b-phead-en">{PAGE_HEAD[id].en}</p>
|
|
<p className="b-phead-ko">
|
|
{id === 'area' ? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title) : PAGE_HEAD[id].title}
|
|
</p>
|
|
<p className="b-phead-note">{PAGE_HEAD[id].note}</p>
|
|
{id === 'area' && areaPills.length > 0 && (
|
|
<div className="b-pills">
|
|
{areaPills.map(([label, href]) => (
|
|
<a key={href} className="b-pill" href={href}>
|
|
{label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
))}
|
|
|
|
{children}
|
|
|
|
{channels.length > 0 && (
|
|
<div data-pg="stay">
|
|
<section className="b-sec" id="channels" aria-labelledby="channels-heading">
|
|
<div className="b-sh">
|
|
<p className="b-sh-en" aria-hidden>
|
|
Channels
|
|
</p>
|
|
<h2 className="b-sh-ko" id="channels-heading">
|
|
공식 채널
|
|
</h2>
|
|
</div>
|
|
<div className="b-list">
|
|
{channels.map((link) => (
|
|
<a key={link.url} className="b-row" href={link.url} target="_blank" rel="noopener noreferrer">
|
|
<span>{channelLabel(link)}</span>
|
|
<span className="r">OPEN ↗</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
<footer className="b-ft">
|
|
<Wordmark className="b-ft-big" name={place.name} english={english} />
|
|
<p>
|
|
{place.name}
|
|
{address && ` · ${address}`}
|
|
</p>
|
|
{hours.length > 0 && <p>{hours.join(' · ')}</p>}
|
|
{(place.phone || instagram) && (
|
|
<p>
|
|
{place.phone && <a href={`tel:${place.phone}`}>{place.phone}</a>}
|
|
{place.phone && instagram && ' · '}
|
|
{instagram && (
|
|
<a href={instagram.url} target="_blank" rel="noopener noreferrer">
|
|
instagram
|
|
</a>
|
|
)}
|
|
</p>
|
|
)}
|
|
{place.legal?.businessRegistrationNumber && (
|
|
<p>
|
|
{place.legal.representative && `대표 ${place.legal.representative} · `}
|
|
사업자등록번호 {place.legal.businessRegistrationNumber}
|
|
{place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`}
|
|
</p>
|
|
)}
|
|
<p className="byline credit">
|
|
<span className="author">작성·운영 {place.name}</span> · 최종 업데이트{' '}
|
|
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
|
|
<br />
|
|
<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer">
|
|
AI O2O
|
|
</a>
|
|
의 Web4Ai로 만든 사이트입니다.
|
|
</p>
|
|
</footer>
|
|
|
|
<div className="b-player">
|
|
<SongPlayer />
|
|
</div>
|
|
|
|
<nav className="b-bar" aria-label="연락 · 예약">
|
|
{address && (
|
|
<a href={naverDirectionsUrl(place.name, place.latitude, place.longitude)} target="_blank" rel="noopener noreferrer nofollow">
|
|
<PinIcon />
|
|
오시는 길
|
|
</a>
|
|
)}
|
|
{kakao && (
|
|
<a href={kakao.url} target="_blank" rel="noopener noreferrer" aria-label={channelLabel(kakao)}>
|
|
<ChatIcon />
|
|
카톡
|
|
</a>
|
|
)}
|
|
{place.phone && (
|
|
<a href={`tel:${place.phone}`}>
|
|
<PhoneIcon />
|
|
전화
|
|
</a>
|
|
)}
|
|
{stayBooking ? (
|
|
<button type="button" className="cta" onClick={() => setBookingOpen(true)}>
|
|
BOOK
|
|
<small>예약하기</small>
|
|
</button>
|
|
) : (
|
|
booking && (
|
|
<a className="cta" href={booking.url} target="_blank" rel="noopener noreferrer">
|
|
BOOK
|
|
<small>{bookingActionLabel(booking)}</small>
|
|
</a>
|
|
)
|
|
)}
|
|
</nav>
|
|
|
|
{stayBooking && (
|
|
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
|
|
<div className="b-bookbox">
|
|
<p className="b-cap">Reservation</p>
|
|
<h3>예약 요청하기</h3>
|
|
<BookingForm />
|
|
</div>
|
|
</Sheet>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|