[feat] solution: 맛집 카드 사진 필터링·지도검색 연결

사진(imageUrl) 없는 맛집은 카드 목록에서 제외한다(명소는 이름 대체 규칙 유지).
맛집·명소 카드의 "검색으로 열기"는 네이버 웹검색 대신 네이버 지도검색으로 연결한다.
빌더 관리자 미리보기(LocalGuide)도 실제 사이트와 동일하게 맞춘다(축제 카드는 기존 웹검색 유지).

검증: site 81건(신규 회귀 3건 포함) 통과. site·frontend 모두 tsc --noEmit 통과.
This commit is contained in:
김성경 2026-09-15 16:28:39 +09:00
parent f2087aad5e
commit a6ddeccdff
7 changed files with 77 additions and 16 deletions

View File

@ -8,6 +8,9 @@ import {walkMinutes} from '../variants/local/walking';
const naverSearch = (q: string) =>
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
/** 네이버지도 검색 — 맛집·명소 카드가 쓴다(실제 사이트 LocalGuideSection과 동일한 규칙). */
export const naverMapSearch = (q: string) => `https://map.naver.com/p/search/${encodeURIComponent(q)}`;
/**
* 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기".
*
@ -15,11 +18,21 @@ const naverSearch = (q: string) =>
* 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다.
* ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다.
*/
function GuideCardView({card, colors, leading}: {card: GuideCard; colors: TemplateItem['colors']; leading?: ReactNode}) {
function GuideCardView({
card,
colors,
leading,
linkUrl = naverSearch,
}: {
card: GuideCard;
colors: TemplateItem['colors'];
leading?: ReactNode;
linkUrl?: (query: string) => string;
}) {
const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined;
return (
<a
href={naverSearch(card.searchQuery)}
href={linkUrl(card.searchQuery)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
@ -74,11 +87,14 @@ export function PlaceCarousel({
cards,
colors,
renderLeading,
linkUrl,
}: {
cards: GuideCard[];
colors: TemplateItem['colors'];
/** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */
renderLeading?: (card: GuideCard) => ReactNode;
/** 카드가 열 링크. 기본은 네이버 웹 검색이고, 맛집·명소는 네이버지도 검색(naverMapSearch)을 넘긴다. */
linkUrl?: (query: string) => string;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [perPage, setPerPage] = useState(1);
@ -171,6 +187,7 @@ export function PlaceCarousel({
card={card}
colors={colors}
leading={renderLeading?.(card)}
linkUrl={linkUrl}
/>
))}
</div>

View File

@ -19,5 +19,5 @@ export {Pill} from './Pill';
export {CtaLink, type CtaStyle} from './CtaLink';
export {Rail} from './Rail';
/* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */
export {PlaceCarousel} from './PlaceCarousel';
export {PlaceCarousel, naverMapSearch} from './PlaceCarousel';
export {WalkFilterTabs} from './WalkFilterTabs';

View File

@ -18,6 +18,7 @@ export function LocalCategorySection({
emptyText,
colors,
renderLeading,
linkUrl,
}: {
icon: LucideIcon;
title: string;
@ -25,6 +26,7 @@ export function LocalCategorySection({
emptyText: string;
colors: TemplateItem['colors'];
renderLeading?: (card: GuideCard) => ReactNode;
linkUrl?: (query: string) => string;
}) {
const [filter, setFilter] = useState<WalkFilterKey>('all');
const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters));
@ -52,7 +54,7 @@ export function LocalCategorySection({
{visible.length === 0 ? (
<EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice>
) : (
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} />
<PlaceCarousel cards={visible} colors={colors} renderLeading={renderLeading} linkUrl={linkUrl} />
)}
</>
)}

View File

@ -9,7 +9,7 @@
*/
import {Calendar, MapPin, Utensils} from 'lucide-react';
import {useLocalGuide} from '@/hooks/useLocalGuide';
import {Pill, SectionBody, SectionFrame, SectionHeading} from '../../primitives';
import {Pill, SectionBody, SectionFrame, SectionHeading, naverMapSearch} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import type {FestivalCard, GuideCard} from './types';
import {LocalCategorySection} from './LocalCategorySection';
@ -46,12 +46,16 @@ export function LocalGuide(props: SectionRenderProps) {
colors={colors}
/>
{/* ★ 맛집은 사진이 없으면 목록에서 뺀다 (2026-09-15, 사장님 지시) — 명소는 이름 활자로 대신하는
규칙을 유지하지만, 맛집은 실제 사이트(LocalGuideSection)와 맞춰 사진 없는 곳을 숨긴다. */}
{/* ★ 맛집·명소 카드는 네이버지도 검색으로 연다 (2026-09-15, 사장님 지시) — 실제 사이트와 동일. */}
<LocalCategorySection
icon={Utensils}
title="주변 맛집"
cards={foods}
cards={foods.filter((card) => card.imageUrl)}
emptyText="주변 맛집·카페 추천은 아직 준비 중입니다."
colors={colors}
linkUrl={naverMapSearch}
/>
<LocalCategorySection
@ -60,6 +64,7 @@ export function LocalGuide(props: SectionRenderProps) {
cards={spots}
emptyText="주변 명소 추천은 아직 준비 중입니다."
colors={colors}
linkUrl={naverMapSearch}
/>
{isStay && (

View File

@ -34,6 +34,16 @@ export function naverSearchUrl(query: string): string {
return `https://search.naver.com/search.naver?query=${encodeURIComponent(query)}`;
}
/**
* 네이버지도 검색으로 보내는 링크.
*
* ★ 주변 맛집·명소 카드의 "검색으로 열기"는 여기로 보낸다 (2026-09-15, 사장님 지시) —
* 손님이 카드를 누르는 목적이 "이 가게가 어디 있나"라, 웹 검색보다 지도가 바로 답이다.
*/
export function naverMapSearchUrl(query: string): string {
return `https://map.naver.com/p/search/${encodeURIComponent(query)}`;
}
/**
* 유튜브 **검색** 주소.
*

View File

@ -2,7 +2,7 @@ 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, naverSearchUrl} from '@site/lib/format';
import {formatKoreanDate, naverMapSearchUrl} from '@site/lib/format';
import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
/**
@ -72,11 +72,14 @@ export function LocalGuideSection() {
const {local} = payload;
// ★ 축제·행사는 여기서 그리지 않는다 (2026-09-03) — 날짜가 지나면 못 가는 것이라
// 아무 때나 가는 맛집·명소와 성격이 다르다. 계절을 축으로 세운 FestivalSection 이 맡는다.
const hasAnything = local.restaurants.length > 0 || local.attractions.length > 0;
// ★ 맛집은 사진이 없으면 아예 목록에서 뺀다 (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 = [...local.restaurants, ...local.attractions];
const all = [...restaurants, ...local.attractions];
const count = (r: (typeof RANGES)[number]) =>
all.filter((place) => r.test(distanceOf(place))).length;
// 비었거나 전체와 똑같은 탭은 세우지 않는다 — 눌러도 그대로인 탭은 고장으로 읽힌다.
@ -126,8 +129,8 @@ export function LocalGuideSection() {
)}
<div className="space-y-10">
{local.restaurants.length > 0 && (
<PlaceList title="주변 맛집" icon={Utensils} places={local.restaurants} within={within} />
{restaurants.length > 0 && (
<PlaceList title="주변 맛집" icon={Utensils} places={restaurants} within={within} />
)}
{local.attractions.length > 0 && (
<PlaceList title="주변 명소" icon={MapPin} places={local.attractions} within={within} />
@ -181,7 +184,7 @@ function PlaceList({
{shown.map((place) => (
<CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
<a
href={naverSearchUrl(place.searchQuery)}
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"
@ -251,7 +254,7 @@ function PlaceList({
.filter((place) => !within(place))
.map((place) => (
<li key={place.name}>
<a href={naverSearchUrl(place.searchQuery)} rel="nofollow">
<a href={naverMapSearchUrl(place.searchQuery)} rel="nofollow">
{place.name}
</a>
{place.distanceText && (

View File

@ -9,9 +9,9 @@ it('renders collected descriptions and addresses with numeric distance filters',
payload.local.attractions = [];
payload.local.restaurants = [
{name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', location: 'Address A',
description: 'Collected description', distanceMeters: 401, distanceText: '400m'},
{name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100},
{name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C'},
description: 'Collected description', distanceMeters: 401, distanceText: '400m', imageUrl: 'https://example.com/a.jpg'},
{name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100, imageUrl: 'https://example.com/b.jpg'},
{name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C', imageUrl: 'https://example.com/c.jpg'},
];
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
expect(html).toContain('Collected description');
@ -19,3 +19,27 @@ it('renders collected descriptions and addresses with numeric distance filters',
expect(html).toMatch(/걸어서 5분 이내 <span[^>]*>1<\/span>/);
expect(html).toContain('Cafe C');
});
it('excludes restaurants without an image', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.local.attractions = [];
payload.local.restaurants = [
{name: 'Cafe With Image', category: 'cafe', searchQuery: 'Cafe With Image', imageUrl: 'https://example.com/a.jpg'},
{name: 'Cafe Without Image', category: 'cafe', searchQuery: 'Cafe Without Image'},
];
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
expect(html).toContain('Cafe With Image');
expect(html).not.toContain('Cafe Without Image');
});
it('links restaurant and attraction cards to Naver Map search, not web search', () => {
const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD);
payload.local.restaurants = [
{name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', imageUrl: 'https://example.com/a.jpg'},
];
payload.local.attractions = [{name: 'Spot A', category: 'spot', searchQuery: 'Spot A'}];
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
expect(html).toContain('https://map.naver.com/p/search/Cafe%20A');
expect(html).toContain('https://map.naver.com/p/search/Spot%20A');
expect(html).not.toContain('search.naver.com');
});