o2o-site-AEO/solution/site/src/layouts/editorial/Shell.tsx
Mina Choi b7b8cb856c [fix] solution/site: 흐린 보조 글자 대비 올림 · 예약 달력 기본으로 펼침
여러 섹션의 opacity-55~70 짜리 보조 텍스트가 밝은 배경에서 너무 흐렸다 — 85~100 으로
올림(Carousel 카운터·SectionHead 리드문·SNS 소식·주변 안내 거리·공식 채널 링크).
StayBookingDemo: "날짜·시간 선택" 토글을 없애고 달력을 기본으로 펼쳐 보여준다 —
접어 두면 손님이 그 버튼을 눌러야만 날짜를 볼 수 있었다.
2026-09-23 13:18:02 +09:00

235 lines
9.9 KiB
TypeScript

import {useEffect, useState, type ReactNode} from 'react';
import {useSite} from '@site/lib/site-context';
import {bookingActionLabel, bookingLinks, channelLabel, primaryChannelLink} from '@site/lib/derive';
import {formatKoreanDate, isoDate} from '@site/lib/format';
// 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴을 타면 섹션 전부가 딸려 온다.
import {MobileTabBar} from '@site/sections/MobileTabBar';
import {GRAIN, useSectionIndex, type IndexEntry} from './SectionHead';
/**
* 편집(잡지)형 껍데기.
*
* ★ 왼쪽 세로 차례가 이 안의 얼굴이다. 잡지의 등(spine)처럼 화면 왼쪽에 붙어 따라오고,
* 스크롤에 따라 지금 읽는 항목만 진해진다. **데스크톱에서만** 세운다 — 좁은 화면에서
* 목차는 자리만 먹고, 그 일은 이미 상단 제호 줄과 하단 고정 바가 한다.
* ★ 차례를 fixed 로 띄우지 않고 flex 한 칸으로 세웠다. fixed 로 얹으면 어두운 판이
* 밑으로 지나갈 때 목차 글자가 통째로 사라진다 — 칸으로 두면 항상 제 종이 위에 있다.
* ★ 제호 줄에 전화·예약을 둔다. 국내 숙박 사이트에서 상단이 답해야 하는 건 그 둘뿐이다.
*/
export function Shell({children}: {children: ReactNode}) {
const payload = useSite();
const {place, site} = payload;
const index = useSectionIndex();
const active = useActiveSection(index);
const booking = bookingLinks(payload)[0];
// ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
const link = primaryChannelLink(payload);
const address = place.roadAddress ?? place.address;
return (
<div
className="flex min-h-screen w-full flex-col"
style={{backgroundColor: 'var(--tpl-bg)', color: 'var(--tpl-text)'}}
>
{/* 제호(masthead) — 얇은 한 줄, 아래는 굵은 실선. 잡지의 표제부다. */}
<header
className="sticky top-0 z-40 w-full border-b-2 backdrop-blur-md"
style={{
backgroundColor: 'color-mix(in srgb, var(--tpl-bg) 90%, transparent)',
borderColor: 'var(--tpl-text)',
}}
>
<div className="flex h-14 items-center justify-between gap-4 px-4 sm:px-6 xl:px-8">
<a href="#top" className="serif min-w-0 truncate text-[length:var(--fs-lead)] font-extrabold">
{place.name}
</a>
<div className="flex shrink-0 items-center gap-4 text-[length:var(--fs-sm)]">
{place.phone && (
<a
href={`tel:${place.phone}`}
className="tap inline-flex items-center underline underline-offset-4"
>
{place.phone}
</a>
)}
{booking && (
<a
href={booking.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center font-bold underline underline-offset-4"
>
{bookingActionLabel(booking)}
</a>
)}
</div>
</div>
</header>
<div className="flex w-full flex-1">
{index.length > 0 && (
<aside
/* self-start 가 없으면 flex 가 높이를 늘려 sticky 가 움직일 자리를 잃는다. */
className="border-line sticky top-14 hidden h-[calc(100vh-3.5rem)] w-44 shrink-0 self-start border-r xl:block"
style={{backgroundImage: GRAIN}}
>
<nav aria-label="차례" className="flex h-full flex-col justify-center gap-0.5 py-8 pl-6 pr-3">
{index.map((entry) => {
const on = entry.anchor === active;
return (
<a
key={entry.anchor}
href={`#${entry.anchor}`}
aria-current={on ? 'true' : undefined}
className="flex items-baseline gap-2 py-2 text-[length:var(--fs-sm)] leading-tight motion-safe:transition-opacity"
style={{opacity: on ? 1 : 0.35}}
>
<span className="shrink-0 text-[length:var(--fs-xs)] font-bold tabular-nums">
{entry.no}
</span>
<span className="truncate" style={{fontWeight: on ? 700 : 400}}>
{entry.label}
</span>
</a>
);
})}
</nav>
</aside>
)}
<div className="flex min-w-0 flex-1 flex-col">{children}</div>
</div>
{/* 판권장(colophon) — 잡지 맨 뒷장. 상호·주소·연락처·법정 표기가 한자리에 남는다. */}
<footer
className="w-full pb-28 pt-12 md:pb-16"
style={{
backgroundColor: 'var(--tpl-inverse)',
color: 'var(--tpl-bg)',
backgroundImage: GRAIN,
}}
>
<div className="shell">
<div aria-hidden className="h-0.5 w-full bg-current opacity-100" />
<div className="grid grid-cols-1 gap-8 pt-8 md:grid-cols-12">
<div className="md:col-span-5">
<p
className="serif"
style={{fontSize: 'clamp(1.5rem, 4vw, 2.5rem)', fontWeight: 800, lineHeight: 1.05}}
>
{place.name}
</p>
{place.englishName && (
<p className="mt-2 text-[length:var(--fs-xs)] uppercase tracking-[0.18em] opacity-100">
{place.englishName}
</p>
)}
</div>
<address className="space-y-2 font-serif text-[length:var(--fs-sm)] not-italic opacity-100 md:col-span-4">
{address && <p>{address}</p>}
{place.phone && (
<p>
<a href={`tel:${place.phone}`} className="underline underline-offset-4">
{place.phone}
</a>
</p>
)}
{place.email && (
<p>
<a href={`mailto:${place.email}`} className="underline underline-offset-4">
{place.email}
</a>
</p>
)}
</address>
{link && (
<nav aria-label="공식 채널" className="md:col-span-3">
<ul className="space-y-1.5 text-[length:var(--fs-sm)]">
<li>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4 opacity-90 transition-opacity hover:opacity-100"
>
{channelLabel(link)}
</a>
</li>
</ul>
</nav>
)}
</div>
<div className="mt-10 flex flex-col gap-2 border-t border-current/20 pt-5 text-[length:var(--fs-xs)] opacity-100 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span>상호: {place.name}</span>
{place.legal?.representative && <span>대표: {place.legal.representative}</span>}
{place.legal?.businessRegistrationNumber && (
<span>사업자등록번호: {place.legal.businessRegistrationNumber}</span>
)}
{place.legal?.mailOrderNumber && (
<span>통신판매업신고: {place.legal.mailOrderNumber}</span>
)}
{place.legal?.licenseNumber && (
<span>
{place.legal.licenseLabel ?? '인허가번호'}: {place.legal.licenseNumber}
</span>
)}
</div>
{/* 마지막 갱신을 화면에도 적는다 — 사람도 AI 도 신선도를 여기서 본다. */}
{/* ★ 작성자·최종 업데이트는 안마다 같은 모양이어야 한다 (2026-09-04, AEO 진단 0/3)
한글 날짜("2026년 9월 4일")는 최신성을 재는 쪽이 못 읽는다. 화면 글자도 ISO 로 적고,
byline 은 E-E-A-T 채점이 본문에서 찾는 값이라 같이 남긴다. 공용 꼬리(SiteFooter)와 한 벌. */}
<p className="byline shrink-0">
<span className="author">작성·운영 {place.name}</span>
{' · 최종 업데이트: '}
<time dateTime={site.updatedAt}>{isoDate(site.updatedAt)}</time>
</p>
</div>
</div>
</footer>
<MobileTabBar />
</div>
);
}
/**
* 지금 읽고 있는 섹션.
*
* ★ 화면 한가운데 얇은 띠(위 45% · 아래 50% 를 잘라낸 나머지)를 지나는 판만
* '읽는 자리'로 본다. 띠를 안 좁히면 긴 섹션 둘이 동시에 걸려 목차가 깜빡인다.
* ★ 서버 렌더에서는 아무것도 진해지지 않는다(useEffect 는 브라우저에서만 돈다) —
* 초기 상태가 양쪽 다 같아서 하이드레이션이 어긋나지 않는다.
*/
function useActiveSection(entries: IndexEntry[]): string {
// 배열은 렌더마다 새로 만들어진다 — 문자열로 굳혀야 effect 가 매 렌더 다시 돌지 않는다.
const anchors = entries.map((entry) => entry.anchor).join(',');
const [active, setActive] = useState('');
useEffect(() => {
if (typeof IntersectionObserver === 'undefined') return;
const targets = anchors
.split(',')
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(records) => {
const hit = records.find((record) => record.isIntersecting);
if (hit) setActive(hit.target.id);
},
{rootMargin: '-45% 0px -50% 0px'},
);
targets.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [anchors]);
return active;
}