목업(/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
162 lines
7.6 KiB
TypeScript
162 lines
7.6 KiB
TypeScript
import {useState} from 'react';
|
|
import {Car, Check, Copy, Navigation} from 'lucide-react';
|
|
import {useSite} from '@/lib/site-context';
|
|
import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl} from '@/lib/format';
|
|
import {sectionName} from '@/lib/derive';
|
|
|
|
/**
|
|
* 오시는 길.
|
|
*
|
|
* ★ 마크업은 목업(/s/stay 의 `<section id="location">`)에서 그대로 옮겼다.
|
|
* 지도 7 : 주소·길찾기 5 의 가로 배치다. 지도를 위에 눕히면 주소가 접힌 화면 밖으로 밀린다.
|
|
* ★ 길찾기 버튼 색은 각 서비스의 브랜드색을 그대로 쓴다(네이버 #03C75A · 카카오 #FEE500 ·
|
|
* 티맵 #0064FF). 템플릿 색으로 칠하면 어느 앱으로 가는지 손님이 못 알아본다.
|
|
* ★ 티맵은 앱 딥링크라 손가락 화면에서만 보인다(`only-touch`).
|
|
*/
|
|
export function LocationSection() {
|
|
const payload = useSite();
|
|
const {place, routes} = payload;
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const address = place.roadAddress ?? place.address;
|
|
if (!address) return null;
|
|
|
|
// 좌표가 있어야 지도도 길찾기도 정확해진다. 없으면 지도를 그리지 않고,
|
|
// 버튼은 검색으로 떨어진다(format.ts) — 틀린 핀을 찍느니 안 찍는다.
|
|
const {latitude: lat, longitude: lng} = place;
|
|
const hasGeo = lat != null && lng != null;
|
|
const q = encodeURIComponent(place.name);
|
|
|
|
const handleCopy = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(address);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
// 클립보드 권한이 없으면 조용히 넘어간다 — 주소는 화면에 그대로 있다.
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section
|
|
id="location"
|
|
aria-labelledby="location-heading"
|
|
className="border-line paper w-full border-b"
|
|
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', 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="location-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">{sectionName(payload, 'map', '오시는 길')}</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="grid gap-6 lg:grid-cols-12 lg:gap-8">
|
|
{hasGeo && (
|
|
<div className="border-line tpl-border overflow-hidden rounded-xl border lg:col-span-7">
|
|
{/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를
|
|
남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */}
|
|
<iframe
|
|
title={`${place.name} 위치 지도`}
|
|
src={osmEmbedUrl(lat, lng)}
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer-when-downgrade"
|
|
className="block aspect-4/3 w-full border-0 sm:aspect-video lg:aspect-auto lg:h-full lg:min-h-[24rem]"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className={hasGeo ? 'lg:col-span-5' : 'lg:col-span-12'}>
|
|
<div className="panel p-5 sm:p-6">
|
|
<p className="label mb-2">주소</p>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* address 태그로 감싼다 — 기계가 "이게 이 업소의 주소"임을 안다. */}
|
|
<address className="text-[length:var(--fs-lead)] font-bold not-italic">{address}</address>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
title="주소 복사"
|
|
className="border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/8 px-2.5 py-1.5 text-[length:var(--fs-xs)] font-medium transition-opacity hover:opacity-70"
|
|
>
|
|
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
|
<span>{copied ? '복사완료' : '복사'}</span>
|
|
</button>
|
|
</div>
|
|
{place.phone && (
|
|
<p className="text-muted mt-2 text-[length:var(--fs-sm)]">
|
|
문의:{' '}
|
|
<a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">
|
|
{place.phone}
|
|
</a>
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-5 flex flex-wrap gap-2.5">
|
|
<a
|
|
href={naverDirectionsUrl(place.name, lat, lng)}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
className="tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#03C75A] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90"
|
|
>
|
|
<Navigation className="size-3.5" aria-hidden="true" />
|
|
<span>네이버 길찾기</span>
|
|
</a>
|
|
<a
|
|
href={kakaoDirectionsUrl(place.name, lat, lng)}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
className="tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#FEE500] px-4 text-[length:var(--fs-sm)] font-bold text-[#191600] transition-opacity hover:opacity-90"
|
|
>
|
|
<Navigation className="size-3.5" aria-hidden="true" />
|
|
<span>카카오 길찾기</span>
|
|
</a>
|
|
{hasGeo && (
|
|
<a
|
|
href={`tmap://route?goalname=${q}&goalx=${lng}&goaly=${lat}&rGoName=${q}&rGoX=${lng}&rGoY=${lat}`}
|
|
className="tap only-touch flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#0064FF] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90"
|
|
>
|
|
<Navigation className="size-3.5" aria-hidden="true" />
|
|
<span>티맵 길찾기</span>
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{routes.length > 0 && (
|
|
<div className="mt-6">
|
|
<h3 className="mb-3 flex items-center gap-2 text-[length:var(--fs-sm)] font-bold">
|
|
<Car className="size-4 opacity-60" aria-hidden="true" />
|
|
<span>주요 거점 소요시간</span>
|
|
</h3>
|
|
<dl className="divide-line border-line divide-y border-t">
|
|
{routes.map((route) => (
|
|
<div
|
|
key={route.destination}
|
|
className="flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]"
|
|
>
|
|
<dt className="text-muted shrink-0">{route.destination}</dt>
|
|
<dd className="text-right font-semibold">
|
|
{[route.byCar, route.byTransit].filter(Boolean).join(' · ') || '—'}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|