From a6ddeccdff3d056f20027f119c958800324c8178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 15 Sep 2026 16:28:39 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20solution:=20=EB=A7=9B=EC=A7=91=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=82=AC=EC=A7=84=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=C2=B7=EC=A7=80=EB=8F=84=EA=B2=80=EC=83=89=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사진(imageUrl) 없는 맛집은 카드 목록에서 제외한다(명소는 이름 대체 규칙 유지). 맛집·명소 카드의 "검색으로 열기"는 네이버 웹검색 대신 네이버 지도검색으로 연결한다. 빌더 관리자 미리보기(LocalGuide)도 실제 사이트와 동일하게 맞춘다(축제 카드는 기존 웹검색 유지). 검증: site 81건(신규 회귀 3건 포함) 통과. site·frontend 모두 tsc --noEmit 통과. --- .../canvas/primitives/PlaceCarousel.tsx | 21 +++++++++++-- .../builder/canvas/primitives/index.ts | 2 +- .../variants/local/LocalCategorySection.tsx | 4 ++- .../canvas/variants/local/LocalGuide.tsx | 9 ++++-- solution/site/src/lib/format.ts | 10 +++++++ .../site/src/sections/LocalGuideSection.tsx | 17 ++++++----- .../site/src/sections/local-guide.test.tsx | 30 +++++++++++++++++-- 7 files changed, 77 insertions(+), 16 deletions(-) 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'); +});