diff --git a/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx b/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx index 9e75aaf..1d94492 100644 --- a/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx +++ b/solution/frontend/src/features/builder/canvas/primitives/PlaceCarousel.tsx @@ -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 ( 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(null); const [perPage, setPerPage] = useState(1); @@ -171,6 +187,7 @@ export function PlaceCarousel({ card={card} colors={colors} leading={renderLeading?.(card)} + linkUrl={linkUrl} /> ))} diff --git a/solution/frontend/src/features/builder/canvas/primitives/index.ts b/solution/frontend/src/features/builder/canvas/primitives/index.ts index d949112..c444d8b 100644 --- a/solution/frontend/src/features/builder/canvas/primitives/index.ts +++ b/solution/frontend/src/features/builder/canvas/primitives/index.ts @@ -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'; diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx index 573bdef..d1f848b 100644 --- a/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/local/LocalCategorySection.tsx @@ -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('all'); const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters)); @@ -52,7 +54,7 @@ export function LocalCategorySection({ {visible.length === 0 ? ( 이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요. ) : ( - + )} )} diff --git a/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx b/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx index 25a6fb5..217c752 100644 --- a/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx +++ b/solution/frontend/src/features/builder/canvas/variants/local/LocalGuide.tsx @@ -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, 사장님 지시) — 실제 사이트와 동일. */} card.imageUrl)} emptyText="주변 맛집·카페 추천은 아직 준비 중입니다." colors={colors} + linkUrl={naverMapSearch} /> {isStay && ( diff --git a/solution/site/src/lib/format.ts b/solution/site/src/lib/format.ts index 3710084..0f3ace5 100644 --- a/solution/site/src/lib/format.ts +++ b/solution/site/src/lib/format.ts @@ -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)}`; +} + /** * 유튜브 **검색** 주소. * diff --git a/solution/site/src/sections/LocalGuideSection.tsx b/solution/site/src/sections/LocalGuideSection.tsx index 876d10e..f28ce98 100644 --- a/solution/site/src/sections/LocalGuideSection.tsx +++ b/solution/site/src/sections/LocalGuideSection.tsx @@ -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('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() { )}
- {local.restaurants.length > 0 && ( - + {restaurants.length > 0 && ( + )} {local.attractions.length > 0 && ( @@ -181,7 +184,7 @@ function PlaceList({ {shown.map((place) => ( !within(place)) .map((place) => (
  • - + {place.name} {place.distanceText && ( diff --git a/solution/site/src/sections/local-guide.test.tsx b/solution/site/src/sections/local-guide.test.tsx index 594924f..98dad55 100644 --- a/solution/site/src/sections/local-guide.test.tsx +++ b/solution/site/src/sections/local-guide.test.tsx @@ -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(); expect(html).toContain('Collected description'); @@ -19,3 +19,27 @@ it('renders collected descriptions and addresses with numeric distance filters', expect(html).toMatch(/걸어서 5분 이내 ]*>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(); + 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(); + 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'); +});