목업(/s/stay)이 참조하는 스타일시트가 서버에서 사라져(404) 그 화면을 기준으로 삼을 수
없었다. 브라우저 캐시에 남아 있던 규칙을 꺼내 레포에 되돌리고, 섹션 마크업을 목업 HTML
에서 그대로 옮겼다. Playwright 대조로 섹션 래퍼·제목 16/17, 안쪽 구조 14/17 일치.
- index.css: --fs-display~--fs-xs(유동 타이포 7) · --section-space · --color-line/muted ·
.h2 .h3 .panel .paper .measure .border-line .text-muted .divide-line .slider-viewport/track
★ 이 토큰이 없던 동안 컴포넌트가 var(--fs-display)를 써도 브라우저가 조용히 무시했다 —
히어로 제목이 본문 크기로 나오던 원인
- HeroSection: 가운데 → 좌하단. 사진 위에 글자를 한가운데 얹으면 피사체를 정확히 가린다
- AnswerBlock·EssentialInfo·Units·Gallery·Location·Faq·LocalGuide·Weather·About:
목업 마크업 그대로. embla 를 scroll-snap 으로 바꿔 스크립트 없이도 레일이 밀린다
- FestivalSection(계절 탭) · StorySection(이야기 탭 래퍼) · items/TripMap(OSM 타일 지도) 신설
★ 지도는 iframe 이 아니라 타일을 직접 깐다 — iframe 은 핀을 하나밖에 못 찍어
"어떤 순서로 도는가" 를 그릴 수 없다
- items: itinerary·event·video kind 추가(파서에 없어 payload 까지 실려 오고도 화면에서
사라지던 것) · Rail 을 slider-viewport/track 으로 · SongsSection 을 턴테이블로
- color.ts: 선 색을 secondary → mix(bg, text, .2). secondary 는 '본문 다음으로 진한
글자색' 이라 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다
- seo/head.ts: --tpl-texture. 없으면 색만 갱지고 면은 매끈해서 인쇄물로 안 보인다
- seo/verify.ts: unitCode·numberOfRooms 를 화면 대조에서 뺀다
★ unitCode 는 ㎡의 ISO 코드로 priceCurrency 와 같은 성격인데 예외에 없었다. 그래서
room_size 가 있는 사업장은 발행 게이트가 전부 막았다(실측: 가은채 객실 12개).
stay 는 객실 2개 + room_size 없음이라 우연히 통과했다
- shared: LocalPlace.imageUrl/distanceMeters · FestivalEntry.imageUrl ·
ItineraryStop 좌표 · TemplateLook.texture · people/chronicle/postcard imageUrl
★ 좌표·사진은 모델이 만드는 칸이 아니다. 공식 API 로 조회해 채운다
검증: tsc 통과 · Playwright 대조(목업 CSS 주입) 섹션 래퍼·제목 16/17, 안쪽 14/17.
남은 차이는 데이터 한계다 — people 사진은 위키에 원본이 없고(original:false),
weather.note 는 정적 페이지에 날씨 문장을 박는 문제라 보류.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP1ykMnWZFtLFpm2mow1Sw
139 lines
6.0 KiB
TypeScript
139 lines
6.0 KiB
TypeScript
import {Phone} from 'lucide-react';
|
|
import {selectPublishable} from '@o2o/shared';
|
|
import {useSite} from '@/lib/site-context';
|
|
import {bookingLinks, channelLabel, essentialRows} from '@/lib/derive';
|
|
import {formatKoreanDate} from '@/lib/format';
|
|
|
|
/**
|
|
* 이용안내 및 예약.
|
|
*
|
|
* ★ 마크업은 목업(/s/stay 의 `<section id="info">`)에서 그대로 옮겼다.
|
|
* ★ 표를 두 덩이로 가른다 — "예약 전 확인"(critical)과 "시설 · 편의".
|
|
* critical 은 틀리면 예약 클레임이 나는 항목이라 손님이 먼저 봐야 하고,
|
|
* 와이파이·주차와 같은 무게로 섞이면 그 위계가 사라진다.
|
|
* ★ 예약 버튼이 이 섹션 안에 있다. 규정을 읽은 자리에서 바로 예약으로 이어지지 않으면
|
|
* 손님은 다시 위로 올라가야 한다.
|
|
* ★ "확인 중인 항목 N개"를 숨기지 않는다 — 없는 척하면 손님이 다른 데서 틀린 값을 찾아 온다.
|
|
*/
|
|
export function EssentialInfoSection() {
|
|
const payload = useSite();
|
|
const rows = essentialRows(payload);
|
|
if (rows.length === 0) return null;
|
|
|
|
// critical 여부는 fact 에만 있다. 표 행(label/value)에 다시 붙여 둘로 가른다.
|
|
const criticalLabels = new Set(
|
|
selectPublishable(payload.facts)
|
|
.filter((fact) => fact.scope === 'place' && fact.critical)
|
|
.map((fact) => fact.label),
|
|
);
|
|
const groups = [
|
|
{title: '예약 전 확인', accent: true, rows: rows.filter((row) => criticalLabels.has(row.label))},
|
|
{title: '시설 · 편의', accent: false, rows: rows.filter((row) => !criticalLabels.has(row.label))},
|
|
].filter((group) => group.rows.length > 0);
|
|
|
|
const hiddenCount = payload.facts.filter(
|
|
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
|
|
).length;
|
|
const links = bookingLinks(payload);
|
|
const phone = payload.place.phone;
|
|
|
|
return (
|
|
<section
|
|
id="info"
|
|
aria-labelledby="info-heading"
|
|
className="border-line paper w-full border-b"
|
|
style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
|
|
>
|
|
<div className="shell">
|
|
<div>
|
|
<header className="mb-6 sm:mb-8">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
|
|
<div className="min-w-0">
|
|
<h2 id="info-heading" className="h2 flex items-center gap-2.5">
|
|
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
|
|
<span className="min-w-0">이용안내 및 예약</span>
|
|
</h2>
|
|
<p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
|
|
방문 전 확인이 필요한 운영 규정과 시설 안내입니다.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
</div>
|
|
|
|
<div className="space-y-8">
|
|
{groups.map((group) => (
|
|
<section key={group.title}>
|
|
<h3 className="border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
|
|
{group.accent && (
|
|
<i
|
|
aria-hidden="true"
|
|
className="h-[1em] w-[3px] shrink-0"
|
|
style={{backgroundColor: 'var(--color-accent)'}}
|
|
/>
|
|
)}
|
|
<span>{group.title}</span>
|
|
</h3>
|
|
<dl className="divide-line divide-y">
|
|
{group.rows.map((row) => (
|
|
<div
|
|
key={row.label}
|
|
className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6"
|
|
>
|
|
<dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0">
|
|
{row.label}
|
|
</dt>
|
|
<dd className="measure text-[length:var(--fs-sm)] font-semibold">{row.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</section>
|
|
))}
|
|
|
|
{(phone || links.length > 0) && (
|
|
<div className="border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between">
|
|
<p className="text-[length:var(--fs-sm)] font-semibold">
|
|
{payload.place.name} 예약은 아래로 받습니다.
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{phone && (
|
|
<a
|
|
href={`tel:${phone}`}
|
|
className="tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
|
|
>
|
|
<Phone className="size-4" aria-hidden="true" />
|
|
<span>{phone}</span>
|
|
</a>
|
|
)}
|
|
{links.map((link) => (
|
|
<a
|
|
key={link.url}
|
|
href={link.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
|
|
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
|
|
>
|
|
{channelLabel(link)}로 예약
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<p className="text-muted mt-5 text-[length:var(--fs-xs)]">
|
|
<span className="flex flex-col gap-1 sm:flex-row sm:justify-between">
|
|
<span>
|
|
{hiddenCount > 0
|
|
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
|
|
: '모든 항목이 사업자 확인을 거쳤습니다.'}
|
|
</span>
|
|
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span>
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|