사장님이 고를 수 있는 숙박 템플릿이 고택 하나뿐이었다. 모두 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건
315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import {useEffect, useState, type ReactNode} 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 {SongPlayer} from '@site/sections/SongPlayer';
|
|
import {BookingForm} from '../paper/BookingForm';
|
|
import {BOOK_EVENT} from '../paper/Rooms';
|
|
import {Sheet} from '../paper/Sheet';
|
|
import {MapPinIcon, PhoneIcon, TalkIcon} from './icons';
|
|
import '../kit/kit.css';
|
|
import './coral.css';
|
|
|
|
const HOUSE_WORD: Record<number, string> = {
|
|
[PlaceCategory.LODGING]: '펜션',
|
|
[PlaceCategory.CAFE]: '카페',
|
|
[PlaceCategory.RESTAURANT]: '가게',
|
|
[PlaceCategory.CLINIC]: '병원',
|
|
};
|
|
|
|
type PageId = 'home' | 'area' | 'stay' | 'story';
|
|
|
|
const PAGE_HEAD: Record<Exclude<PageId, 'home'>, {kicker: string; title: string; note: string}> = {
|
|
area: {
|
|
kicker: 'Travel',
|
|
title: '지역 소개',
|
|
note: '대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기입니다.',
|
|
},
|
|
stay: {
|
|
kicker: 'Reservation',
|
|
title: '이용안내 · 예약',
|
|
note: '객실 상세와 요금, 예약하는 방법입니다.',
|
|
},
|
|
story: {
|
|
kicker: 'Story',
|
|
title: '이야기',
|
|
note: '사장님이 남기는 글과 다녀가신 분들의 후기, 그리고 엽서 한 장입니다.',
|
|
},
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
export function Frame({children}: {children: ReactNode}) {
|
|
const payload = useSite();
|
|
const {place, site} = payload;
|
|
const page = usePage();
|
|
const [bookingOpen, setBookingOpen] = useState(false);
|
|
useEffect(() => {
|
|
const open = () => setBookingOpen(true);
|
|
window.addEventListener(BOOK_EVENT, open);
|
|
return () => window.removeEventListener(BOOK_EVENT, open);
|
|
}, []);
|
|
|
|
const tabs: {label: string; page: PageId}[] = [
|
|
{label: `${HOUSE_WORD[place.category] ?? '가게'} 소개`, page: 'home'},
|
|
];
|
|
if (isSectionEnabled(payload, 'local')) tabs.push({label: '지역 소개', page: 'area'});
|
|
tabs.push({label: '이용안내 · 예약', page: 'stay'});
|
|
tabs.push({label: '이야기', page: 'story'});
|
|
|
|
const booking = bookingLinks(payload)[0];
|
|
const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null;
|
|
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 links = publicLinks(payload);
|
|
const instagram = links.find((link) => /instagram\.com/i.test(link.url));
|
|
const kakao = payload.links.find((link) => link.confirmed && /(pf|open)\.kakao\.com/i.test(link.url));
|
|
const address = place.roadAddress ?? place.address;
|
|
const english = place.englishName?.trim();
|
|
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 barItems = [
|
|
address && (
|
|
<a key="dir" href={naverDirectionsUrl(place.name, place.latitude, place.longitude)} target="_blank" rel="noopener noreferrer nofollow">
|
|
<MapPinIcon />
|
|
길찾기
|
|
</a>
|
|
),
|
|
kakao && (
|
|
<a key="talk" href={kakao.url} target="_blank" rel="noopener noreferrer">
|
|
<TalkIcon />
|
|
카톡
|
|
</a>
|
|
),
|
|
place.phone && (
|
|
<a key="tel" href={`tel:${place.phone}`}>
|
|
<PhoneIcon />
|
|
전화
|
|
</a>
|
|
),
|
|
].filter(Boolean);
|
|
const cta = 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>
|
|
) : null;
|
|
|
|
return (
|
|
<div className="w4-coral" data-page={page}>
|
|
<header className="top">
|
|
<div className="wrap">
|
|
{place.phone ? (
|
|
<a className="ico" href={`tel:${place.phone}`} aria-label="전화 걸기">
|
|
<PhoneIcon />
|
|
</a>
|
|
) : (
|
|
<span className="ico" />
|
|
)}
|
|
<a className={english ? 'logo' : 'logo logo--ko'} href="#/">
|
|
{english || place.name}
|
|
</a>
|
|
{stayBooking ? (
|
|
<button type="button" className="badge" onClick={() => setBookingOpen(true)}>
|
|
Book
|
|
<br />
|
|
Now
|
|
</button>
|
|
) : booking ? (
|
|
<a className="badge" href={booking.url} target="_blank" rel="noopener noreferrer">
|
|
Book
|
|
<br />
|
|
Now
|
|
</a>
|
|
) : (
|
|
<span className="ico" />
|
|
)}
|
|
</div>
|
|
<nav className="tabs" aria-label="주요 메뉴">
|
|
{tabs.map((tab) => (
|
|
<a
|
|
key={tab.page}
|
|
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
|
|
className={tab.page === page ? 'on' : undefined}
|
|
>
|
|
{tab.label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
<div className="hd-cta">
|
|
{place.phone && (
|
|
<a className="hd-tel" href={`tel:${place.phone}`}>
|
|
<PhoneIcon />
|
|
{place.phone}
|
|
</a>
|
|
)}
|
|
{cta}
|
|
</div>
|
|
</header>
|
|
|
|
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
|
|
<div key={id} data-pg={id}>
|
|
<section className="sec pagehead">
|
|
<p className="kicker">{PAGE_HEAD[id].kicker}</p>
|
|
<h2>
|
|
{id === 'area'
|
|
? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title)
|
|
: PAGE_HEAD[id].title}
|
|
</h2>
|
|
<p className="lead">{PAGE_HEAD[id].note}</p>
|
|
{id === 'area' && areaPills.length > 0 && (
|
|
<div className="pills">
|
|
{areaPills.map(([label, href]) => (
|
|
<a key={href} className="pill" href={href}>
|
|
{label}
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
))}
|
|
|
|
{children}
|
|
|
|
{links.length > 0 && (
|
|
<div data-pg="stay">
|
|
<section className="sec" id="channels">
|
|
<p className="kicker">Channel</p>
|
|
<h2>공식 채널</h2>
|
|
<div className="list">
|
|
{links.map((link) => (
|
|
<a key={link.url} className="row" href={link.url} target="_blank" rel="noopener noreferrer">
|
|
<span>{channelLabel(link)}</span>
|
|
<span className="r">열기 ↗</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
<footer>
|
|
<div className="wrap">
|
|
{place.phone && (
|
|
<a className="ftel" href={`tel:${place.phone}`}>
|
|
{place.phone}
|
|
</a>
|
|
)}
|
|
<b className="fname">{place.name}</b>
|
|
{address && <p>{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>
|
|
</div>
|
|
<p className={english ? 'giant' : 'giant giant--ko'} aria-hidden>
|
|
{english || place.name}
|
|
</p>
|
|
</footer>
|
|
|
|
<div className="player">
|
|
<SongPlayer />
|
|
</div>
|
|
|
|
{(barItems.length > 0 || cta) && (
|
|
<nav
|
|
className="tabbar"
|
|
aria-label="연락"
|
|
style={{gridTemplateColumns: [...barItems.map(() => '1fr'), ...(cta ? ['1.6fr'] : [])].join(' ')}}
|
|
>
|
|
{barItems}
|
|
{cta}
|
|
</nav>
|
|
)}
|
|
|
|
{stayBooking && (
|
|
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
|
|
<div className="sheetbody">
|
|
<h3>예약 요청하기</h3>
|
|
<BookingForm />
|
|
</div>
|
|
</Sheet>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|