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, {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 = {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('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, 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(); 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(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 (
{english || place.name} {locality && {locality}}
{place.phone && ( {place.phone} )} {stayBooking ? ( ) : ( booking && ( {bookingActionLabel(booking)} ) )}
{place.phone && ( CALL )}
{(Object.keys(PAGE_HEAD) as Exclude[]).map((id) => (

{PAGE_HEAD[id].en}

{id === 'area' ? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title) : PAGE_HEAD[id].title}

{PAGE_HEAD[id].note}

{id === 'area' && areaPills.length > 0 && (
{areaPills.map(([label, href]) => ( {label} ))}
)}
))} {children} {channels.length > 0 && (

Channels

공식 채널

{channels.map((link) => ( {channelLabel(link)} OPEN ↗ ))}
)}

{place.name} {address && ` · ${address}`}

{hours.length > 0 &&

{hours.join(' · ')}

} {(place.phone || instagram) && (

{place.phone && {place.phone}} {place.phone && instagram && ' · '} {instagram && ( instagram )}

)} {place.legal?.businessRegistrationNumber && (

{place.legal.representative && `대표 ${place.legal.representative} · `} 사업자등록번호 {place.legal.businessRegistrationNumber} {place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`}

)}

작성·운영 {place.name} · 최종 업데이트{' '}
AI O2O 의 Web4Ai로 만든 사이트입니다.

{stayBooking && ( setBookingOpen(false)} label="예약 요청" plain>

Reservation

예약 요청하기

)}
); }