o2o-site-AEO/solution/site/src/sections/LocalGuideSection.tsx
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00

262 lines
12 KiB
TypeScript

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<string>('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 (
<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">
{local.restaurants.length > 0 && (
<PlaceList title="주변 맛집" icon={Utensils} places={local.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} gap={0.75}>
{shown.map((place) => (
<CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
<a
href={naverSearchUrl(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' 는 그 근거다. */}
{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(meters(place.distanceText)) ?? 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.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={naverSearchUrl(place.searchQuery)} rel="nofollow">
{place.name}
</a>
{place.distanceText && (
<span>
{' '}
{place.distanceText}
{walkText(meters(place.distanceText)) && ` · ${walkText(meters(place.distanceText))}`}
</span>
)}
{place.description && <p>{place.description}</p>}
</li>
))}
</ul>
{shown.length === 0 && (
<p className="text-muted text-[length:var(--fs-sm)]">이 거리 안에는 없습니다.</p>
)}
</div>
);
}