앞 커밋(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>
333 lines
11 KiB
TypeScript
333 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 {MapIcon, PhoneIcon, TalkIcon} from './icons';
|
|
import '../kit/kit.css';
|
|
import './cinema.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'>, {label: string; title: string; note: string}> = {
|
|
area: {
|
|
label: 'AREA',
|
|
title: '지역 소개',
|
|
note: '대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기입니다.',
|
|
},
|
|
stay: {
|
|
label: 'STAY',
|
|
title: '이용안내 · 예약',
|
|
note: '상세 안내와 요금, 예약하는 방법입니다.',
|
|
},
|
|
story: {
|
|
label: '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;
|
|
}
|
|
|
|
function useScrolled(): boolean {
|
|
const [scrolled, setScrolled] = useState(false);
|
|
useEffect(() => {
|
|
const sync = () => setScrolled(window.scrollY > 40);
|
|
sync();
|
|
window.addEventListener('scroll', sync, {passive: true});
|
|
return () => window.removeEventListener('scroll', sync);
|
|
}, []);
|
|
return scrolled;
|
|
}
|
|
|
|
export function Frame({children}: {children: ReactNode}) {
|
|
const payload = useSite();
|
|
const {place, site} = payload;
|
|
const page = usePage();
|
|
const scrolled = useScrolled();
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [bookingOpen, setBookingOpen] = useState(false);
|
|
useEffect(() => {
|
|
const open = () => setBookingOpen(true);
|
|
window.addEventListener(BOOK_EVENT, open);
|
|
return () => window.removeEventListener(BOOK_EVENT, open);
|
|
}, []);
|
|
useEffect(() => setMenuOpen(false), [page]);
|
|
|
|
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 kakao = payload.links.find((link) => link.confirmed && /kakao/i.test(link.url));
|
|
const address = place.roadAddress ?? place.address;
|
|
const hasSongs = (payload.songs ?? []).some((song) => song.audioUrl);
|
|
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 barItems = [address ? 'map' : null, kakao ? 'talk' : null, place.phone ? 'tel' : null].filter(Boolean);
|
|
const hasCta = Boolean(stayBooking || booking);
|
|
const showBar = barItems.length > 0 || hasCta;
|
|
const columns = [...barItems.map(() => '1fr'), ...(hasCta ? ['1.7fr'] : [])].join(' ');
|
|
|
|
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>
|
|
)
|
|
);
|
|
|
|
return (
|
|
<div className="w4-cinema" data-page={page}>
|
|
<header className={scrolled || page !== 'home' || menuOpen ? 'top solid' : 'top'}>
|
|
<div className="wrap">
|
|
<a className="logo" href="#/">
|
|
{place.name}
|
|
</a>
|
|
<nav className="dnav" 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>
|
|
<button
|
|
type="button"
|
|
className="menu"
|
|
aria-label="메뉴"
|
|
aria-expanded={menuOpen}
|
|
onClick={() => setMenuOpen((was) => !was)}
|
|
>
|
|
<span />
|
|
</button>
|
|
</div>
|
|
<nav className="drawer glass" aria-label="주요 메뉴" hidden={!menuOpen}>
|
|
{tabs.map((tab) => (
|
|
<a
|
|
key={tab.page}
|
|
href={tab.page === 'home' ? '#/' : `#/${tab.page}`}
|
|
aria-current={tab.page === page ? 'page' : undefined}
|
|
>
|
|
{tab.label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
</header>
|
|
|
|
<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>
|
|
|
|
{(Object.keys(PAGE_HEAD) as Exclude<PageId, 'home'>[]).map((id) => (
|
|
<div key={id} data-pg={id}>
|
|
<section className="wrap pagehead">
|
|
<p className="label">{PAGE_HEAD[id].label}</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}
|
|
|
|
{channels.length > 0 && (
|
|
<div data-pg="stay">
|
|
<section className="wrap" id="channels">
|
|
<p className="label">CHANNELS</p>
|
|
<h2>공식 채널</h2>
|
|
<div className="rows">
|
|
{channels.map((link) => (
|
|
<a key={link.url} className="line" href={link.url} target="_blank" rel="noopener noreferrer">
|
|
<span>{channelLabel(link)}</span>
|
|
<span className="r">열기 ↗</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
|
|
<footer>
|
|
<div className="wrap">
|
|
<p className="fname">{place.name}</p>
|
|
{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>
|
|
</footer>
|
|
|
|
{hasSongs && (
|
|
<div className={showBar ? 'player glass' : 'player glass low'}>
|
|
<SongPlayer />
|
|
</div>
|
|
)}
|
|
|
|
{showBar && (
|
|
<nav className="tabbar glass" aria-label="연락 · 예약" style={{gridTemplateColumns: columns}}>
|
|
{address && (
|
|
<a href={naverDirectionsUrl(place.name, place.latitude, place.longitude)} target="_blank" rel="noopener noreferrer nofollow">
|
|
<MapIcon />
|
|
길찾기
|
|
</a>
|
|
)}
|
|
{kakao && (
|
|
<a href={kakao.url} target="_blank" rel="noopener noreferrer">
|
|
<TalkIcon />
|
|
카톡
|
|
</a>
|
|
)}
|
|
{place.phone && (
|
|
<a href={`tel:${place.phone}`}>
|
|
<PhoneIcon />
|
|
전화
|
|
</a>
|
|
)}
|
|
{cta}
|
|
</nav>
|
|
)}
|
|
|
|
{stayBooking && (
|
|
<Sheet open={bookingOpen} onClose={() => setBookingOpen(false)} label="예약 요청" plain>
|
|
<div className="sform">
|
|
<h3>예약 요청하기</h3>
|
|
<BookingForm />
|
|
</div>
|
|
</Sheet>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|