o2o-site-AEO/solution/site/src/sections/LocalGuideSection.tsx
김성경 a6ddeccdff [feat] solution: 맛집 카드 사진 필터링·지도검색 연결
사진(imageUrl) 없는 맛집은 카드 목록에서 제외한다(명소는 이름 대체 규칙 유지).
맛집·명소 카드의 "검색으로 열기"는 네이버 웹검색 대신 네이버 지도검색으로 연결한다.
빌더 관리자 미리보기(LocalGuide)도 실제 사이트와 동일하게 맞춘다(축제 카드는 기존 웹검색 유지).

검증: site 81건(신규 회귀 3건 포함) 통과. site·frontend 모두 tsc --noEmit 통과.
2026-09-15 16:28:39 +09:00

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>
);
}