사진(imageUrl) 없는 맛집은 카드 목록에서 제외한다(명소는 이름 대체 규칙 유지). 맛집·명소 카드의 "검색으로 열기"는 네이버 웹검색 대신 네이버 지도검색으로 연결한다. 빌더 관리자 미리보기(LocalGuide)도 실제 사이트와 동일하게 맞춘다(축제 카드는 기존 웹검색 유지). 검증: site 81건(신규 회귀 3건 포함) 통과. site·frontend 모두 tsc --noEmit 통과.
279 lines
13 KiB
TypeScript
279 lines
13 KiB
TypeScript
import {ArrowUpRight, MapPin, Utensils} from 'lucide-react';
|
|
import type {LocalPlace} from '@o2o/shared';
|
|
import {useState} from 'react';
|
|
import {useSite} from '@site/lib/site-context';
|
|
import {formatKoreanDate, naverMapSearchUrl} from '@site/lib/format';
|
|
import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
|
|
|
|
/**
|
|
* 거리 탭.
|
|
*
|
|
* ★ 왜 도보 '분' 인가 (2026-09-04, 사장님 제안: "~km 이내 탭")
|
|
* 손님이 실제로 묻는 건 "걸어서 갈 수 있나"지 몇 km 인가가 아니다. 그래서 미터를
|
|
* **도보 시간(80m/분)** 으로 바꿔 라벨에 쓴다. 실측 데이터도 그렇게 갈린다 —
|
|
* 맛집 열둘이 전부 500m 안이고, 명소는 13m 부터 2.7km 까지 흩어져 있다.
|
|
* ★ 거른 것은 지우지 않고 접는다(hidden). 잘라내면 검색·AI 가 나머지를 못 읽는다.
|
|
* ★ 라벨에 '이내 / 이상' 을 붙인다 (2026-09-04, 사장님 지시)
|
|
* "걸어서 10분" 만 적으면 10분짜리만 모아 둔 칸으로 읽힌다. 경계값이 어느 쪽인지가
|
|
* 이 탭의 전부라, 포함 방향을 말로 박아 둔다. 마지막 칸은 나머지를 받는 '이상' 이다.
|
|
*/
|
|
const WALK = 80; // m/분. 도보 속도의 통상값이다.
|
|
const RANGES = [
|
|
{id: 'all', label: '전체', test: () => true},
|
|
{id: 'w5', label: '걸어서 5분 이내', test: (m: number) => m <= 5 * WALK},
|
|
{id: 'w10', label: '걸어서 10분 이내', test: (m: number) => m <= 10 * WALK},
|
|
{id: 'far', label: '걸어서 10분 이상', test: (m: number) => m > 10 * WALK},
|
|
] as const;
|
|
|
|
/**
|
|
* 숙소에서 걸리는 시간.
|
|
*
|
|
* ★ 카드마다 낸다 (2026-09-07, 사장님 지시)
|
|
* 거리 탭은 "걸어서 10분 이내"로 갈라 놓고 카드에는 "248m"만 찍혀 있었다. 손님은
|
|
* 248m 가 그 탭 안인지 밖인지 매번 암산해야 했다 — 탭이 이미 쓰는 환산을 카드도 쓴다.
|
|
* ★ 새 값을 payload 에 넣지 않고 여기서 환산한다. `distanceText` 는 수집이 준 사실이고
|
|
* 분은 그 사실에서 나온 것이라, 두 곳에 적으면 갈린다(거리 탭도 같은 상수를 본다).
|
|
* ★ 직선거리 기준이라 실제로 걷는 길보다 짧다. 그래서 **'약'** 을 붙이고 각주로 근거를 적는다.
|
|
*/
|
|
function walkText(m: number): string | undefined {
|
|
if (!Number.isFinite(m)) return undefined;
|
|
return `도보 약 ${Math.max(1, Math.round(m / WALK))}분`;
|
|
}
|
|
|
|
/** "135m" · "1.1km" → 미터. 못 읽으면 Infinity — 모르는 거리를 가깝다고 하지 않는다. */
|
|
function meters(text: string | undefined): number {
|
|
const m = /^([\d.]+)\s*(km|m)$/i.exec((text ?? '').trim());
|
|
if (!m) return Infinity;
|
|
return Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1);
|
|
}
|
|
|
|
function distanceOf(place: LocalPlace): number {
|
|
// 숫자가 없는 옛 payload만 표기 문자열로 복원한다.
|
|
if (place.distanceMeters != null) {
|
|
return Number.isFinite(place.distanceMeters) && place.distanceMeters >= 0
|
|
? place.distanceMeters
|
|
: Infinity;
|
|
}
|
|
return meters(place.distanceText);
|
|
}
|
|
|
|
/**
|
|
* 주변 정보.
|
|
*
|
|
* "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다.
|
|
* ★ 우리가 남의 가게 URL 을 지어내지 않는다 — 검색 링크로만 보낸다.
|
|
* 틀린 링크는 사용자를 엉뚱한 데로 보내고, 그 책임은 이 홈페이지가 진다.
|
|
*
|
|
* ★ 880px 한 단에 세로로 쌓던 자리다. 맛집 5 · 명소 5 면 화면 두 개 분량의
|
|
* 한 줄짜리 띠가 이어졌다. 카드 그리드로 벌리면 같은 내용이 한 화면에 들어온다.
|
|
*/
|
|
export function LocalGuideSection() {
|
|
const payload = useSite();
|
|
const {local} = payload;
|
|
// ★ 축제·행사는 여기서 그리지 않는다 (2026-09-03) — 날짜가 지나면 못 가는 것이라
|
|
// 아무 때나 가는 맛집·명소와 성격이 다르다. 계절을 축으로 세운 FestivalSection 이 맡는다.
|
|
// ★ 맛집은 사진이 없으면 아예 목록에서 뺀다 (2026-09-15, 사장님 지시) —
|
|
// 명소는 이름 활자로 대신하는 규칙을 유지하지만, 맛집은 사진 없는 곳을 보여주지 않는다.
|
|
const restaurants = local.restaurants.filter((place) => place.imageUrl);
|
|
const hasAnything = restaurants.length > 0 || local.attractions.length > 0;
|
|
const [range, setRange] = useState<string>('all');
|
|
if (!hasAnything) return null;
|
|
|
|
const all = [...restaurants, ...local.attractions];
|
|
const count = (r: (typeof RANGES)[number]) =>
|
|
all.filter((place) => r.test(distanceOf(place))).length;
|
|
// 비었거나 전체와 똑같은 탭은 세우지 않는다 — 눌러도 그대로인 탭은 고장으로 읽힌다.
|
|
const ranges = RANGES.filter((r, i) => i === 0 || (count(r) > 0 && count(r) < all.length));
|
|
const active = ranges.find((r) => r.id === range) ?? RANGES[0];
|
|
const within = (place: LocalPlace) => active.test(distanceOf(place));
|
|
|
|
return (
|
|
<Section
|
|
id="guide"
|
|
tone="alt"
|
|
title="주변 안내"
|
|
lead={`${payload.place.addressLocality ?? '주변'} 지역의 맛집 · 명소 안내입니다.`}
|
|
aside={
|
|
local.syncedAt ? (
|
|
<p className="text-muted text-[length:var(--fs-xs)]">
|
|
{formatKoreanDate(local.syncedAt)} 갱신
|
|
</p>
|
|
) : undefined
|
|
}
|
|
footnote={<>도보 시간은 숙소에서 잰 직선거리를 분속 {WALK}m 로 환산한 값입니다. 실제로 걷는 길은 이보다 깁니다.</>}
|
|
>
|
|
{ranges.length > 1 && (
|
|
<div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="거리">
|
|
{ranges.map((r) => {
|
|
const on = r.id === range;
|
|
const n = count(r);
|
|
return (
|
|
<button
|
|
key={r.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={on}
|
|
onClick={() => setRange(r.id)}
|
|
className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
|
|
style={
|
|
on
|
|
? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)', borderColor: 'transparent'}
|
|
: undefined
|
|
}
|
|
>
|
|
{r.label} <span className="tabular-nums opacity-70">{n}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-10">
|
|
{restaurants.length > 0 && (
|
|
<PlaceList title="주변 맛집" icon={Utensils} places={restaurants} within={within} />
|
|
)}
|
|
{local.attractions.length > 0 && (
|
|
<PlaceList title="주변 명소" icon={MapPin} places={local.attractions} within={within} />
|
|
)}
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function GroupHeading({icon: Icon, children}: {icon: typeof MapPin; children: string}) {
|
|
return (
|
|
<h3 className="border-line mb-4 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
|
|
<Icon className="size-4 opacity-50" />
|
|
<span>{children}</span>
|
|
</h3>
|
|
);
|
|
}
|
|
|
|
function PlaceList({
|
|
title,
|
|
icon: Icon,
|
|
places,
|
|
within,
|
|
}: {
|
|
title: string;
|
|
icon: typeof MapPin;
|
|
places: LocalPlace[];
|
|
within: (place: LocalPlace) => boolean;
|
|
}) {
|
|
const shown = places.filter(within);
|
|
return (
|
|
<div>
|
|
<GroupHeading icon={Icon}>{title}</GroupHeading>
|
|
|
|
{/*
|
|
★ 격자를 레일로 바꿨다 (2026-09-04, 사장님: "모바일 스크롤이 너무 길다")
|
|
실측 390px 에서 이 섹션 하나가 3,726px — 전체 21,132px 의 18% 였다. 맛집 12 + 명소 12를
|
|
2열 격자로 쌓으니 한 묶음이 여섯 줄이었다. 옆으로 밀면 한 줄이면 끝난다.
|
|
★ 스물네 장이 전부 DOM 에 남는다 — 잘라내면 검색·AI 가 나머지를 못 읽는다.
|
|
*/}
|
|
{/*
|
|
★ 접은 칸을 레일 **밖으로** 뺐다 (2026-09-04, 사장님: "드래그하면 겉돈다")
|
|
처음엔 걸러낸 칸을 레일 안에 두고 hidden 만 걸었다. 그런데 embla 는 슬라이드 폭을 재서
|
|
스냅 위치를 잡는데, hidden 은 폭이 0 이라 **0px 짜리 스냅이 사이사이에 섞인다** —
|
|
끌면 손가락과 카드가 어긋나고(겉돌고) 화살표가 빈 자리에 멈춘다. key 로 다시 태워도
|
|
접힌 칸이 남아 있는 한 같다.
|
|
★ 그래도 **문서에서는 지우지 않는다.** 레일 뒤에 접힌 목록으로 남긴다 —
|
|
검색·AI 는 거리 탭과 무관하게 스물넷을 다 읽어야 한다.
|
|
*/}
|
|
<Carousel key={shown.length} label={title}>
|
|
{shown.map((place) => (
|
|
<CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
|
|
<a
|
|
href={naverMapSearchUrl(place.searchQuery)}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
className="panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85"
|
|
>
|
|
{/*
|
|
★ 사진이 없어도 **자리를 비우지 않는다.** 빈 회색 상자를 두면 "준비 중"으로
|
|
읽히고, 카드마다 높이가 달라져 격자가 무너진다. 사진이 없으면 이름 활자를
|
|
대신 넣는다 — 인물 열전이 쓰는 규칙과 같다(아티팩트: "빈 회색 상자를 두지 않는다").
|
|
*/}
|
|
<span className="relative block aspect-4/3 overflow-hidden">
|
|
{place.imageUrl ? (
|
|
<img
|
|
src={place.imageUrl}
|
|
alt={`${place.name} 사진`}
|
|
loading="lazy"
|
|
decoding="async"
|
|
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
/>
|
|
) : (
|
|
<span
|
|
className="serif grid size-full place-items-center px-3 text-center text-[length:var(--fs-lead)] leading-tight"
|
|
style={{backgroundColor: 'color-mix(in oklab, currentColor 9%, transparent)'}}
|
|
aria-hidden
|
|
>
|
|
{place.name}
|
|
</span>
|
|
)}
|
|
{/* 거리는 사진 위에 얹는다 — 손님이 카드에서 제일 먼저 찾는 값이다.
|
|
★ 분을 앞에, 거리를 뒤에. 답이 '분'이고 'm' 는 그 근거다. */}
|
|
{(Number.isFinite(distanceOf(place)) || place.distanceText) && (
|
|
<span
|
|
className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold"
|
|
style={{
|
|
backgroundColor: 'color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)',
|
|
color: 'var(--tpl-bg, #fff)',
|
|
}}
|
|
>
|
|
{walkText(distanceOf(place)) ?? place.distanceText}
|
|
<span className="ml-1 font-normal opacity-70">{place.distanceText}</span>
|
|
</span>
|
|
)}
|
|
</span>
|
|
|
|
<span className="flex flex-1 flex-col gap-1 p-3.5">
|
|
<span className="text-[length:var(--fs-sm)] font-bold">{place.name}</span>
|
|
{place.location && (
|
|
<span className="text-muted text-[length:var(--fs-xs)] break-words">{place.location}</span>
|
|
)}
|
|
{place.description && (
|
|
<span className="text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed">
|
|
{place.description}
|
|
</span>
|
|
)}
|
|
<span className="text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100">
|
|
<span>검색으로 열기</span>
|
|
<ArrowUpRight className="size-3.5" />
|
|
</span>
|
|
</span>
|
|
</a>
|
|
</CarouselSlide>
|
|
))}
|
|
</Carousel>
|
|
|
|
{/* 화면 밖으로 걸러진 곳 — 이름과 설명만 문서에 남긴다. */}
|
|
<ul hidden>
|
|
{places
|
|
.filter((place) => !within(place))
|
|
.map((place) => (
|
|
<li key={place.name}>
|
|
<a href={naverMapSearchUrl(place.searchQuery)} rel="nofollow">
|
|
{place.name}
|
|
</a>
|
|
{place.distanceText && (
|
|
<span>
|
|
{' '}
|
|
{place.distanceText}
|
|
{walkText(distanceOf(place)) && ` · ${walkText(distanceOf(place))}`}
|
|
</span>
|
|
)}
|
|
{place.description && <p>{place.description}</p>}
|
|
{place.location && <p>{place.location}</p>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{shown.length === 0 && (
|
|
<p className="text-muted text-[length:var(--fs-sm)]">이 거리 안에는 없습니다.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|