import {ArrowUpRight, MapPin, Utensils} from 'lucide-react'; import type {LocalPlace} from '@o2o/shared'; import {useState} from 'react'; import {useSite} from '@/lib/site-context'; import {formatKoreanDate, naverSearchUrl} from '@/lib/format'; import {Carousel, CarouselSlide, Section} from '@/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); } /** * 주변 정보. * * "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다. * ★ 우리가 남의 가게 URL 을 지어내지 않는다 — 검색 링크로만 보낸다. * 틀린 링크는 사용자를 엉뚱한 데로 보내고, 그 책임은 이 홈페이지가 진다. * * ★ 880px 한 단에 세로로 쌓던 자리다. 맛집 5 · 명소 5 면 화면 두 개 분량의 * 한 줄짜리 띠가 이어졌다. 카드 그리드로 벌리면 같은 내용이 한 화면에 들어온다. */ export function LocalGuideSection() { const payload = useSite(); const {local} = payload; // ★ 축제·행사는 여기서 그리지 않는다 (2026-09-03) — 날짜가 지나면 못 가는 것이라 // 아무 때나 가는 맛집·명소와 성격이 다르다. 계절을 축으로 세운 FestivalSection 이 맡는다. const hasAnything = local.restaurants.length > 0 || local.attractions.length > 0; const [range, setRange] = useState('all'); if (!hasAnything) return null; const all = [...local.restaurants, ...local.attractions]; const count = (r: (typeof RANGES)[number]) => all.filter((place) => r.test(meters(place.distanceText))).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(meters(place.distanceText)); return (
{formatKoreanDate(local.syncedAt)} 갱신

) : undefined } footnote={<>도보 시간은 숙소에서 잰 직선거리를 분속 {WALK}m 로 환산한 값입니다. 실제로 걷는 길은 이보다 깁니다.} > {ranges.length > 1 && (
{ranges.map((r) => { const on = r.id === range; const n = count(r); return ( ); })}
)}
{local.restaurants.length > 0 && ( )} {local.attractions.length > 0 && ( )}
); } function GroupHeading({icon: Icon, children}: {icon: typeof MapPin; children: string}) { return (

{children}

); } function PlaceList({ title, icon: Icon, places, within, }: { title: string; icon: typeof MapPin; places: LocalPlace[]; within: (place: LocalPlace) => boolean; }) { const shown = places.filter(within); return (
{title} {/* ★ 격자를 레일로 바꿨다 (2026-09-04, 사장님: "모바일 스크롤이 너무 길다") 실측 390px 에서 이 섹션 하나가 3,726px — 전체 21,132px 의 18% 였다. 맛집 12 + 명소 12를 2열 격자로 쌓으니 한 묶음이 여섯 줄이었다. 옆으로 밀면 한 줄이면 끝난다. ★ 스물네 장이 전부 DOM 에 남는다 — 잘라내면 검색·AI 가 나머지를 못 읽는다. */} {/* ★ 접은 칸을 레일 **밖으로** 뺐다 (2026-09-04, 사장님: "드래그하면 겉돈다") 처음엔 걸러낸 칸을 레일 안에 두고 hidden 만 걸었다. 그런데 embla 는 슬라이드 폭을 재서 스냅 위치를 잡는데, hidden 은 폭이 0 이라 **0px 짜리 스냅이 사이사이에 섞인다** — 끌면 손가락과 카드가 어긋나고(겉돌고) 화살표가 빈 자리에 멈춘다. key 로 다시 태워도 접힌 칸이 남아 있는 한 같다. ★ 그래도 **문서에서는 지우지 않는다.** 레일 뒤에 접힌 목록으로 남긴다 — 검색·AI 는 거리 탭과 무관하게 스물넷을 다 읽어야 한다. */} {shown.map((place) => ( {/* ★ 사진이 없어도 **자리를 비우지 않는다.** 빈 회색 상자를 두면 "준비 중"으로 읽히고, 카드마다 높이가 달라져 격자가 무너진다. 사진이 없으면 이름 활자를 대신 넣는다 — 인물 열전이 쓰는 규칙과 같다(아티팩트: "빈 회색 상자를 두지 않는다"). */} {place.imageUrl ? ( {`${place.name} ) : ( {place.name} )} {/* 거리는 사진 위에 얹는다 — 손님이 카드에서 제일 먼저 찾는 값이다. ★ 분을 앞에, 거리를 뒤에. 답이 '분'이고 'm' 는 그 근거다. */} {place.distanceText && ( {walkText(meters(place.distanceText)) ?? place.distanceText} {place.distanceText} )} {place.name} {place.description && ( {place.description} )} 검색으로 열기 ))} {/* 화면 밖으로 걸러진 곳 — 이름과 설명만 문서에 남긴다. */} {shown.length === 0 && (

이 거리 안에는 없습니다.

)}
); }