o2o-site-AEO/solution/site/src/layouts/oasi/Shell.tsx
김성경 a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00

401 lines
20 KiB
TypeScript

import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
import type {CSSProperties, ReactNode} from 'react';
import {isoDate} from '@site/lib/format';
import {useSite} from '@site/lib/site-context';
import {
bookingActionLabel,
bookingLabel,
bookingLinks,
contactLinks,
heroFacts,
isSectionEnabled,
unitSpec,
} from '@site/lib/derive';
// ★ 배럴(`@/sections`)을 거치면 껍데기 하나가 사이트의 모든 섹션을 끌고 들어온다.
// 껍데기는 하단 고정 바 하나만 필요하다 — 파일을 직접 가리킨다.
import {MobileTabBar} from '@site/sections/MobileTabBar';
/**
* 본문 섹션의 색 띠를 걷어낸다 (2026-09-04, 사장님 지적).
*
* ★ 실측: 히어로는 투명(바탕 #e5ddd2)인데 소개가 rgb(219,211,200), 예약 전 확인이
* rgb(222,213,200), 객실이 다시 투명, 요금·이용정보가 rgb(222,213,200) 이었다.
* 밝기가 오르락내리락하는 띠가 여덟 번 반복된다. 이 안에는 **면이 없다** —
* Hero 주석의 "버튼 상자를 만들지 않는다"와 같은 규칙이 섹션에도 걸려야 한다.
* ★ !important 인 이유: 공용 Section 이 배경색과 세로 여백을 **인라인 style** 로 박는다
* (lib/ui/Section.tsx:82). 인라인은 선택자 특정도로 못 이기므로 이 자리에서만 강제한다.
* 제 폴더가 직접 그린 판(Hero·Rooms)은 `data-oasi` 로 표시해 건너뛴다 — 이미 투명이고
* 위 여백을 스스로 정한다.
* ★ 글자색도 같이 강제한다. tone='dark' 섹션이 인라인으로 밝은 글자를 넣는데,
* 면이 사라지면 그 글자가 갱지 위에 하얗게 떠서 안 읽힌다.
* ★ 아래 실선(border-b)도 지운다. 색 띠를 지우고 선만 남기면 이번엔 줄만 여덟 개 그어진다.
* ★ `.shell` 의 좌우 여백을 0 으로 돌린다. 껍데기가 이미 본문 폭을 잡아 뒀는데
* 섹션 안에서 한 번 더 들어가서, 실측 1440px 에서 공용 섹션 제목만 40px 안쪽에
* 있었다(히어로·객실 569px ↔ 소개·요금 609px).
*
* ★ 뒤의 두 규칙은 **본문 칸이 716px 로 고정**이라 생기는 어긋남을 되돌린다.
* 공용 섹션은 1200px 지면을 전제로 `lg:`·`xl:` 에서 칸을 나누는데, 그 중단점은
* 화면 폭이지 이 칸의 폭이 아니다. 이 껍데기는 화면이 아무리 넓어도 칸이 안 넓어진다.
* - #about: 사진 7 · 글 5 로 갈리면 글 칸이 275px 다 — 한 줄에 열네 자.
* 게다가 세로 가운데 정렬이라 짧은 사진이 허공에 뜨고 위아래로 200px 씩 빈다.
* → 위아래로 쌓아 사진도 글도 칸을 다 쓰게 한다(글은 .measure 가 줄 길이를 잡는다).
* - #summary: 값 표가 xl 에서 두 칸으로 갈려 한 칸이 179px 이 됐다.
* 환불 규정 한 문장이 여덟 줄로 감긴다 → 한 칸으로 되돌린다.
*
* ─────────────────────────────────────────────────────────────────────────────
* 남아 있던 마지막 면 — 공용 `.panel` (2026-09-04, 사장님 "다 고쳐")
*
* ★ 섹션 띠를 지우고 나니 `.panel`(index.css:197, 글자색 5% 틴트 + 실선)만 남아
* 이 안에서 유일하게 떠 있는 상자가 됐다. 실측 64개 — FAQ 32 · 주변 24 · 축제 4 ·
* 이용정보 2 · 오시는 길 1 · 날씨 1.
* ★ 그냥 지우면 FAQ 서른둘이 구분 없는 글 덩어리가 된다. 그래서 **면을 걷고 구분을
* 다시 세운다** — 인쇄물이 상자 없이 목록을 가르는 두 가지, 가는 괘선과 들여쓰기다.
* ★ !important 를 쓰지 않는다. 이 <style> 은 캐스케이드 레이어 **밖**이라
* `@layer components` 의 `.panel` 도 `@layer utilities` 의 `px-5` 도 그냥 이긴다.
* 위쪽 규칙들이 !important 인 건 인라인 style 을 이겨야 해서지 특정도 때문이 아니다.
* ★ `.oasi-page` 밖으로 새면 안 된다 — `index.css` 를 고치면 1~6안이 전부 바뀐다.
*
* - #faq: 항목마다 윗줄 괘선 하나. 항목 사이 여백(gap-3)은 0 으로 — 괘선이 경계를
* 맡는데 여백까지 두면 줄 뜬 상자로 다시 읽힌다. 대신 두 단 사이는 16px → 40px:
* 실측 본문 칸이 716px 뿐이라 16px 이면 두 단의 활자가 한 덩어리로 붙는다.
* 답변은 질문 첫 글자 자리(1.875rem = 'Q.' 칸 1.125rem + gap-3 0.75rem)에 매달아
* 들여쓰고, 질문과 답변 사이의 실선은 뺀다 — 괘선이 두 종류면 어느 게 항목 경계인지
* 눈이 못 고른다. 'Q.' 칸에 폭을 박는 건 들여쓰기 눈금을 맞추기 위해서다(실측 17.4px).
* - #info: `divide-y` 가 이미 줄 사이를 긋고 있었다. 상자만 걷으면 그게 곧 표다.
* 좌우 여백(px-5)을 0 으로 돌려 항목 이름이 섹션 제목과 같은 왼쪽 선에 서게 한다.
* 목록 맨 위에 줄을 **덧긋지 않는다** — 묶음 제목('예약 전 확인')이 이미 밑줄을
* 달고 있어서, 실측 16px 간격으로 같은 굵기의 줄이 두 개 그어졌다.
* 값이 전부 짧을 때만 뜨는 격자 판(`dl > div.panel`, 이 payload 에는 안 나온다)은
* 칸이 따로 떨어져 있어 줄 사이를 잇는 실선이 없다 — 칸마다 윗줄을 하나씩 준다.
* - #location: 주소 칸도 같은 규칙. 지도 액자(1px 실선)는 **그대로 둔다** —
* 사진·지도를 실선으로 두르는 건 편집 문법이지 여기서 걷어내는 '틴트 면'이 아니다.
* 거점 표는 이 payload 에 없지만(routes 0건) 같은 안을 쓰는 다른 사이트에서는 뜬다.
* - #guide·#festival: 사진이 이미 덩어리를 가른다. 괘선을 더 긋지 않고 캡션의 좌우
* 여백만 0 으로 — 글이 사진 왼쪽 변에 붙는 인쇄물의 캡션 규칙이다. 축제 카드는
* sm 아래에서 썸네일이 왼쪽에 서는 가로 배치라 그때는 왼쪽 여백을 남긴다.
* - #weather: 값이 한 줄이라 가를 것이 없다. 여백만 0 으로.
*
* ★ 거점 표의 칸을 `thead`/`tbody` 로 짚은 이유 — **이 문자열도 소스 파일이다.**
* Tailwind 스캐너는 이 파일의 글자를 전부 훑어 유틸리티 후보를 뽑는다. 선택자에
* 유틸리티와 이름이 같은 태그 하나를 적었더니 공용 CSS 에 쓰지도 않는 규칙이
* 하나 구워졌다(실측: 번들 해시가 index-Dm9iRWS8 → index-Ca3hpIbE 로 바뀐다).
* 해시가 바뀌면 3안 하나 고치자고 **여섯 사이트를 다 다시 구워야** 한다.
*/
const PLATE_CSS = `
.oasi-page main > section:not([data-oasi]) {
background-color: transparent !important;
background-image: none !important;
border-color: transparent !important;
color: var(--tpl-text) !important;
padding-block: var(--oasi-gap) 0 !important;
}
.oasi-page main > section:not([data-oasi]) .shell {
max-width: none;
padding-inline: 0;
}
.oasi-page main > section#about > .shell > .grid {
display: block;
}
.oasi-page main > section#about > .shell > .grid > * + * {
margin-top: 2.5rem;
}
.oasi-page main > section#summary dl {
grid-template-columns: minmax(0, 1fr);
}
/*
* 붙여넣기 아이템 카드 — 소식 · 영상 · 추천 일정 (2026-09-04, 사장님: "1,2,3안 다 맞추세요")
*
* ★ 이 카드들은 .panel 이 아니라 제 인라인 style 로 면을 그린다(items/common.tsx 의
* ITEM_CARD·ITEM_BORDER). 그래서 위 .panel 규칙이 안 닿았고, 섹션 띠를 다 지운 3안에서
* **소식 카드만 갱지 결에 2px 테두리를 두른 상자로 혼자 서 있었다.**
* ★ w4-paper 의 결은 1안(레트로 갱지)의 것이다. 여기서는 결을 지우고 테두리를 실선 한 겹으로
* 낮춘다. 상자를 아예 없애지는 않는다 — 전단 여섯 장이 경계 없이 붙으면 어디까지가 한 장인지
* 안 보인다. 면은 빼고 테두리만 남기는 게 이 안의 타협점이다.
* ★ 배경은 transparent 로 두지 않는다. 사진 없는 공지 카드가 종이에 바로 얹히면
* 본문과 구분이 사라진다 — 아주 옅은 틴트 한 겹만 남긴다.
* ★ 영상은 뺐다 (2026-09-04, 사장님: "영상 border 안 예쁘다")
* 영상 카드는 상자가 아니라 **표지 사진 한 장**이고, 쇼츠라 9:16 세로다. li 에 틴트를 깔면
* 영상보다 넓은 회색 판이 뒤에 깔려 액자처럼 보인다. 사진은 제 테두리가 곧 경계다.
*/
.oasi-page section#event li,
.oasi-page section#itinerary article {
background-image: none !important;
background-color: color-mix(in oklab, currentcolor 3%, transparent) !important;
border-width: 1px !important;
box-shadow: none !important;
}
.oasi-page .panel,
.oasi-page .panel-sunken {
background-color: transparent;
border-width: 0;
border-radius: 0;
box-shadow: none;
}
.oasi-page main > section#faq > .shell > div:last-of-type {
row-gap: 0;
column-gap: 2.5rem;
}
.oasi-page main > section#faq details {
margin-block: 0;
border-top: 1px solid var(--site-line);
}
.oasi-page main > section#faq summary {
padding-inline: 0;
}
.oasi-page main > section#faq summary > span:first-child {
width: 1.125rem;
}
.oasi-page main > section#faq summary + div {
border-top-width: 0;
padding-inline: 1.875rem 0;
padding-top: 0.5rem;
}
.oasi-page main > section#info dl.panel > div {
padding-inline: 0;
}
.oasi-page main > section#info dl > div.panel {
border-top: 1px solid var(--site-line);
padding-inline: 0;
}
.oasi-page main > section#location .panel {
border-top: 1px solid var(--site-line);
padding-inline: 0;
}
.oasi-page main > section#location :is(thead, tbody) :is(th, td):first-child {
padding-left: 0;
}
.oasi-page main > section#location :is(thead, tbody) :is(th, td):last-child {
padding-right: 0;
}
.oasi-page main > section#weather .panel {
padding-inline: 0;
}
.oasi-page main > section#guide .panel > span:last-child {
padding-inline: 0;
}
@media (min-width: 40rem) {
.oasi-page main > section#festival .panel > span:last-child {
padding-inline: 0;
}
}
`;
/**
* ★ 섹션 사이 여백은 이제 **유일한 경계**다. 색 띠와 실선이 사라졌으니 여백이 좁으면
* 섹션이 이어 붙어 보인다. 템플릿이 정한 값(--tpl-section-space, 이 안은 7rem)을
* 상한으로 쓰되 모바일에서는 3.5rem 까지 줄인다.
* 공용 --section-space(상한 같음, 하한 2.25rem)를 그대로 쓰지 않는 이유는,
* 그 값은 '위아래 둘 다' 붙던 값이라 경계 하나로 쓰기에는 좁다.
*/
const OASI_VARS = {
'--oasi-gap': 'clamp(3.5rem, 7vw, var(--tpl-section-space, 4rem))',
backgroundColor: 'var(--tpl-bg)',
color: 'var(--tpl-text)',
} as CSSProperties;
/**
* oasi 껍데기 — **왼쪽 세로 메뉴 + 아주 큰 여백 + 작은 글씨.**
*
* ★ 다른 안과 갈리는 지점은 '상단 가로 헤더가 없다' 하나다.
* 메뉴가 왼쪽에 세로로 서면 본문 폭이 좁아지고, 좁은 폭에 작은 글씨를 넣어야
* 줄 길이가 맞는다 — 여백·글자 크기가 전부 여기서 따라온다.
* ★ 카드·그림자를 쓰지 않는다. 이 안에서 면을 가르는 건 가는 실선 하나뿐이다.
* ★ 왼쪽 칼럼은 `sticky` 다 — 본문이 길어도 메뉴가 따라온다. 좁은 화면에서는
* 위로 올라가 가로 메뉴가 된다(sticky 를 걸면 모바일에서 화면 절반을 먹는다).
*/
export function Shell({children}: {children: ReactNode}) {
const payload = useSite();
const {place} = payload;
const spec = unitSpec(payload);
const booking = bookingLinks(payload)[0];
const channels = contactLinks(payload);
const menu = [
{label: '처음', href: '#top', show: true},
{label: '소개', href: '#about', show: isSectionEnabled(payload, 'intro')},
{label: spec.label, href: '#units', show: payload.units.length > 0},
{label: '이용 정보', href: '#info', show: true},
{label: '주변', href: '#guide', show: isSectionEnabled(payload, 'local')},
{label: '오시는 길', href: '#location', show: true},
{label: '자주 묻는 질문', href: '#faq', show: payload.faqs.length > 0},
].filter((item) => item.show);
/*
* 유틸 띠에 놓는 짧은 문구 셋.
* ★ 문구를 지어내지 않는다 — 확인된 fact 를 먼저 쓰고(체크인 16:00), 모자라면
* 사장님이 켜 둔 섹션 이름으로 채운다. 둘 다 없으면 띠를 그리지 않는다.
*/
const strip = [
...heroFacts(payload).map((row) => `${row.label} ${row.value}`),
...payload.theme.sections
.filter((section) => section.enabled && section.name?.trim())
.map((section) => section.name.trim()),
].slice(0, 3);
const slogan = payload.narrative.tagline ?? payload.narrative.heroSubline;
const address = place.roadAddress ?? place.address;
return (
<div className="oasi-page flex min-h-screen w-full flex-col" style={OASI_VARS}>
<style dangerouslySetInnerHTML={{__html: PLATE_CSS}} />
{/* 아래 여백을 위보다 크게 잡는다 — 섹션이 판 없이 여백으로만 끊기는데,
마지막 섹션과 꼬리 실선 사이가 섹션 간격보다 좁으면 꼬리가 본문에 붙어 보인다. */}
<div className="mx-auto flex w-full max-w-[72rem] flex-col gap-10 px-5 pb-16 pt-10 lg:flex-row lg:gap-14 lg:px-10 lg:pb-24 lg:pt-16">
{/* 왼쪽 칼럼 — 워드마크 → 넉넉한 여백 → 세로 메뉴. 300px 가 원본 폭이다. */}
<header className="lg:sticky lg:top-12 lg:h-fit lg:w-[18.75rem] lg:shrink-0">
<a href="#top" className="block">
<span
className="serif block leading-none"
/* 자간·굵기를 인라인으로 덮는다 — 템플릿 기본값은 굵고 자간이 음수다.
이 안의 워드마크는 '가늘고 넓게' 가 정체성이라 그대로 두면 안이 안 산다. */
style={{
fontSize: 'var(--fs-display)',
fontWeight: 300,
letterSpacing: '0.14em',
}}
>
{place.name}
</span>
{place.englishName && (
<span className="text-muted mt-3 block text-[length:var(--fs-xs)] tracking-[0.24em]">
{place.englishName}
</span>
)}
</a>
<nav aria-label="주요 메뉴" className="mt-12 lg:mt-24">
{/* 좁은 화면에서는 두 줄로 접힌다 — gap-y 가 없으면 두 줄이 붙어 한 덩어리로 읽힌다. */}
<ul className="flex flex-wrap items-center gap-x-6 gap-y-1 lg:block">
{menu.map((item, index) => (
<li key={item.label}>
<a
href={item.href}
/* ★ 스크롤 위치를 따라가며 표시를 옮기지 않는다. 그건 자바스크립트가
떠야 도는데, 이 사이트는 크롤러가 보는 HTML 이 정본이다.
문서를 연 시점의 위치(맨 위)만 표시한다. */
aria-current={index === 0 ? 'true' : undefined}
className={`tap inline-flex items-center text-[length:var(--fs-sm)] transition-opacity hover:opacity-100 ${
index === 0 ? '' : 'text-muted'
}`}
>
{item.label}
</a>
</li>
))}
</ul>
</nav>
{booking && (
<a
href={booking.url}
target="_blank"
rel="noopener noreferrer"
className="tap mt-2 inline-flex items-center text-[length:var(--fs-sm)] underline underline-offset-[6px]"
style={{color: 'var(--color-brand)'}}
>
{bookingActionLabel(booking)}
</a>
)}
</header>
<div className="flex min-w-0 flex-1 flex-col">
{/* 유틸 띠 — 위아래 실선 사이에 짧은 문구 셋, 오른쪽 끝에 아주 작은 슬로건. */}
{(strip.length > 0 || slogan) && (
<div className="border-line flex flex-col gap-3 border-y py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
{strip.length > 0 && (
<ul className="text-muted flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 text-[length:var(--fs-xs)]">
{strip.map((text, index) => (
<li key={text} className="flex items-center gap-4">
{/* 구분선은 항목 사이에만. 첫 항목 앞에 세로줄이 서면 띠가 잘려 보인다. */}
{index > 0 && (
<span aria-hidden className="opacity-40">
|
</span>
)}
<span>{text}</span>
</li>
))}
</ul>
)}
{slogan && (
<p className="text-muted measure shrink-0 text-[length:var(--fs-xs)] leading-[1.9] sm:max-w-[18rem] sm:text-right">
<HeroCatchphrase>{slogan}</HeroCatchphrase>
</p>
)}
</div>
)}
{children}
</div>
</div>
{/* 하단 — 아주 작은 글씨 3열. 어두운 면을 깔지 않는다(이 안에는 색 띠가 없다). */}
<footer className="border-line mt-auto w-full border-t">
<div className="mx-auto grid w-full max-w-[72rem] grid-cols-1 gap-8 px-5 pb-28 pt-10 text-[length:var(--fs-xs)] leading-[1.9] sm:grid-cols-3 lg:px-10 lg:pb-14">
<p>[{place.name}]</p>
{channels.length > 0 ? (
<ul className="space-y-1">
{channels.map((link) => (
<li key={link.url}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-[5px]"
>
{/* 채널 코드 → 사람이 읽는 이름. 수집된 링크 제목은 상호가 붙어 있어 쓰지 않는다. */}
{bookingLabel(link)}
</a>
</li>
))}
</ul>
) : (
<span />
)}
<div className="text-muted space-y-1 sm:text-right">
{address && <p>{address}</p>}
{place.phone && (
<p>
<a href={`tel:${place.phone}`} className="underline underline-offset-[5px]">
{place.phone}
</a>
</p>
)}
{place.email && <p>{place.email}</p>}
{place.legal?.representative && <p>대표 {place.legal.representative}</p>}
{place.legal?.businessRegistrationNumber && (
<p>사업자등록번호 {place.legal.businessRegistrationNumber}</p>
)}
{place.legal?.mailOrderNumber && <p>통신판매업신고 {place.legal.mailOrderNumber}</p>}
{place.legal?.licenseNumber && (
<p>
{place.legal.licenseLabel ?? '인허가번호'} {place.legal.licenseNumber}
</p>
)}
{/* ★ 작성자·최종 업데이트는 안마다 같은 모양이어야 한다 (2026-09-04, AEO 진단 0/3)
한글 날짜("2026년 9월 4일")는 최신성을 재는 쪽이 못 읽는다. 화면 글자도 ISO 로 적고,
byline 은 E-E-A-T 채점이 본문에서 찾는 값이라 같이 남긴다. 공용 꼬리(SiteFooter)와 한 벌. */}
<p className="byline">
<span className="author">작성·운영 {place.name}</span>
{' · 최종 업데이트: '}
<time dateTime={payload.site.updatedAt}>{isoDate(payload.site.updatedAt)}</time>
</p>
</div>
</div>
</footer>
<MobileTabBar />
</div>
);
}