o2o-site-AEO/solution/site/src/layouts/boutique/Frame.tsx
Mina Choi fad20e8958 [feat] solution/site,shared: 새 템플릿 8종 업종 확장 — 앞 커밋에서 빠진 본체
앞 커밋(18a2874)에 템플릿 등록과 레이아웃 변경이 빠지고 다른 작업의 새 파일
(services/activity_feed.py, 어디서도 아직 import 하지 않음)이 대신 들어갔다.
같은 작업 폴더를 다른 세션이 함께 쓰고 있었다.

- templates.json: cafe · restaurant · clinic 에 라운드~솔숲 8종
- layouts/*/Rooms.tsx: 객실별 '예약 요청' 버튼은 숙박일 때만, 영문 라벨은 unitWord
- layouts/*/Frame.tsx · Intro.tsx: 이용안내 머리말 · 미니 블로그 설명을 업종 중립으로

eslint·tsc 통과, vitest 123 passed, 33개 사이트 굽기 성공

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:58:29 +09:00

387 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 lodging = place.category === PlaceCategory.LODGING;
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">{tab.page === 'stay' && !lodging ? 'Guide' : 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">{id === 'stay' && !lodging ? 'Guide' : 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>
);
}