Compare commits
3 Commits
820a2e9e35
...
4cd756108d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cd756108d | |||
| 217d0853bc | |||
| a6ddeccdff |
@ -5,12 +5,34 @@ from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import places, site_publish_logs, site_versions, sites
|
||||
from common.enums import BuildStatus, ErrorType, SiteStatus
|
||||
from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites
|
||||
from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
def _primary_photo_subquery():
|
||||
"""place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행).
|
||||
|
||||
site_payload.primary_media 와 같은 규칙 — 객실·메뉴 사진(unit_id 있음)이 아닌 첫 장,
|
||||
sort_order 순. `.correlate(places)` 라서 바깥 쿼리가 `places` 를 셀렉트에 들고 있어야 한다.
|
||||
sites.thumbnail_url 이 비어 있을 때(Azure 썸네일 저장소 미설정 등) 서비스 계층이 이걸로
|
||||
대신 채운다 — 여기서는 후보만 얹고, 언제 쓸지는 서비스 계층 몫이다."""
|
||||
return (
|
||||
select(place_photos.url)
|
||||
.where(
|
||||
place_photos.place_id == places.place_id,
|
||||
place_photos.deleted == False, # noqa: E712
|
||||
place_photos.status == MediaStatus.APPROVED.value,
|
||||
place_photos.unit_id.is_(None),
|
||||
)
|
||||
.order_by(place_photos.sort_order.asc(), place_photos.created_at.asc())
|
||||
.limit(1)
|
||||
.correlate(places)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
|
||||
# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다.
|
||||
class ISiteCRUD(ABC):
|
||||
@abstractmethod
|
||||
@ -23,6 +45,7 @@ class ISiteCRUD(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
"""(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@ -67,6 +90,7 @@ class ISiteCRUD(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]:
|
||||
"""(ErrorType, [(site, place, primary_photo_url)])."""
|
||||
pass
|
||||
|
||||
|
||||
@ -98,10 +122,16 @@ class SiteCRUD(ISiteCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
|
||||
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
|
||||
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진.
|
||||
(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).
|
||||
|
||||
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
|
||||
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
|
||||
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.
|
||||
|
||||
★ primary_photo_url 은 site_payload.primary_media 와 같은 규칙(사진 중 객실·메뉴가 아닌
|
||||
첫 장, sort_order 순)으로 고른 place_photos.url 이다 — sites.thumbnail_url 이 비어 있을 때
|
||||
(Azure 썸네일 저장소 미설정 등으로 재호스팅에 실패한 경우) 서비스 계층이 이걸로 대신 채운다.
|
||||
여기서는 후보만 얹고, "발행한 적 있는 줄에만 쓴다"는 판단은 서비스 계층 몫이다."""
|
||||
try:
|
||||
where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
|
||||
|
||||
@ -111,7 +141,7 @@ class SiteCRUD(ISiteCRUD):
|
||||
total = int(cnt_rows[0] or 0) if cnt_rows else 0
|
||||
|
||||
query = (
|
||||
select(places, sites, site_versions.built_at)
|
||||
select(places, sites, site_versions.built_at, _primary_photo_subquery())
|
||||
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
|
||||
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
|
||||
.where(where)
|
||||
@ -265,14 +295,18 @@ class SiteCRUD(ISiteCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]:
|
||||
"""발행된 사이트 + 그 사업장을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다.
|
||||
"""발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다.
|
||||
(ErrorType, [(site, place, primary_photo_url)]).
|
||||
|
||||
★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로
|
||||
돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 —
|
||||
여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다."""
|
||||
여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다.
|
||||
|
||||
★ primary_photo_url 은 list_owner_sites 와 같은 서브쿼리(_primary_photo_subquery) —
|
||||
sites.thumbnail_url 이 비어 있을 때 showcase_service 가 이걸로 대신 채운다."""
|
||||
try:
|
||||
query = (
|
||||
select(sites, places)
|
||||
select(sites, places, _primary_photo_subquery())
|
||||
.join(places, places.place_id == sites.place_id)
|
||||
.where(
|
||||
sites.status == SiteStatus.PUBLISHED.value,
|
||||
|
||||
@ -97,6 +97,8 @@ class MySiteData(WebPacketProtocol):
|
||||
template_id: Optional[str] = None
|
||||
published_at: Optional[datetime] = None
|
||||
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
|
||||
# Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발) 빌더가 쓰는 대표 사진으로 대신 채운다
|
||||
# (site_service._my_site_row) — 이때는 `?v=` 가 없다.
|
||||
thumbnail_url: Optional[str] = None
|
||||
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
|
||||
needs_rebuild: bool = False
|
||||
|
||||
@ -37,8 +37,11 @@ class ShowcaseService:
|
||||
# ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면
|
||||
# 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳').
|
||||
url=f"/s/{site_payload.publish_slug(place, site)}",
|
||||
thumbnail_url=site.thumbnail_url,
|
||||
# ★ sites.thumbnail_url 은 Azure 썸네일 저장소가 꺼져 있으면(로컬 개발 등) 비어
|
||||
# 있다 — 이 목록은 이미 PUBLISHED 만 걷었으므로, 그때는 빌더가 쓰는 대표 사진
|
||||
# (primary_photo_url)으로 대신 채운다(services/site_service._my_site_row 와 같은 규칙).
|
||||
thumbnail_url=site.thumbnail_url or primary_photo_url,
|
||||
)
|
||||
for site, place in rows
|
||||
for site, place, primary_photo_url in rows
|
||||
]
|
||||
return res
|
||||
|
||||
@ -460,14 +460,24 @@ class SiteService:
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
res.result.SetResult(err_type)
|
||||
return res
|
||||
res.sites = [self._my_site_row(place, site, built_at) for place, site, built_at in rows]
|
||||
res.sites = [
|
||||
self._my_site_row(place, site, built_at, primary_photo_url)
|
||||
for place, site, built_at, primary_photo_url in rows
|
||||
]
|
||||
res.total = total
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
def _my_site_row(place, site, built_at) -> MySiteData:
|
||||
def _my_site_row(place, site, built_at, primary_photo_url) -> MySiteData:
|
||||
# ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
|
||||
changed = place.content_updated_at
|
||||
# ★ sites.thumbnail_url 은 발행할 때 Azure 에 대표 사진을 재호스팅해야 채워진다
|
||||
# (services/site_thumbnail.store) — 저장소가 안 꺼져 있으면(로컬 개발 등) 늘 비어 있다.
|
||||
# 그래도 "한 번이라도 발행한 줄은 그림"이라는 화면 규칙은 지켜야 하므로, 빌더가 이미
|
||||
# 쓰고 있는 대표 사진(place_photos, primary_photo_url)으로 대신 채운다 — 발행 전 줄에는
|
||||
# 쓰지 않는다(그 규칙은 published_at 유무로 가른다: crud.site_crud.list_owner_sites).
|
||||
ever_published = site is not None and getattr(site, "published_at", None) is not None
|
||||
thumbnail_url = getattr(site, "thumbnail_url", None) or (primary_photo_url if ever_published else None)
|
||||
return MySiteData(
|
||||
place_id=place.place_id,
|
||||
name=place.name,
|
||||
@ -480,7 +490,7 @@ class SiteService:
|
||||
domain=getattr(site, "domain", None),
|
||||
template_id=getattr(site, "template_id", None),
|
||||
published_at=getattr(site, "published_at", None),
|
||||
thumbnail_url=getattr(site, "thumbnail_url", None),
|
||||
thumbnail_url=thumbnail_url,
|
||||
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
|
||||
)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 **검색** 주소.
|
||||
*
|
||||
|
||||
@ -113,7 +113,7 @@ export function EssentialInfoSection() {
|
||||
{otherRows.length > 0 && <Rows title="시설 · 편의" rows={otherRows} />}
|
||||
{guides.filter((link) => link.stayGuide?.reservation).map((link) => (
|
||||
<Rows key={`reservation-${link.url}`} title="예약 공지" emphasis rows={[
|
||||
{label: 'NOL 예약 공지', value: link.stayGuide?.reservation ?? ''},
|
||||
{label: '예약 공지', value: link.stayGuide?.reservation ?? ''},
|
||||
]} />
|
||||
))}
|
||||
<BookingRow />
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user