o2o-site-AEO/solution/site/src/layouts/coral/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

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>
);
}