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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
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.database.model.models import place_photos, places, site_publish_logs, site_versions, sites
|
||||||
from common.enums import BuildStatus, ErrorType, SiteStatus
|
from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
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 로 스코프한다.
|
# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다.
|
||||||
class ISiteCRUD(ABC):
|
class ISiteCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -23,6 +45,7 @@ class ISiteCRUD(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]:
|
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
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -67,6 +90,7 @@ class ISiteCRUD(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]:
|
async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]:
|
||||||
|
"""(ErrorType, [(site, place, primary_photo_url)])."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -98,10 +122,16 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
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]:
|
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 이라 사이트가 없는 사업장
|
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
|
||||||
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
|
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.
|
||||||
|
|
||||||
|
★ primary_photo_url 은 site_payload.primary_media 와 같은 규칙(사진 중 객실·메뉴가 아닌
|
||||||
|
첫 장, sort_order 순)으로 고른 place_photos.url 이다 — sites.thumbnail_url 이 비어 있을 때
|
||||||
|
(Azure 썸네일 저장소 미설정 등으로 재호스팅에 실패한 경우) 서비스 계층이 이걸로 대신 채운다.
|
||||||
|
여기서는 후보만 얹고, "발행한 적 있는 줄에만 쓴다"는 판단은 서비스 계층 몫이다."""
|
||||||
try:
|
try:
|
||||||
where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
|
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
|
total = int(cnt_rows[0] or 0) if cnt_rows else 0
|
||||||
|
|
||||||
query = (
|
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(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)
|
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
|
||||||
.where(where)
|
.where(where)
|
||||||
@ -265,14 +295,18 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]:
|
async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]:
|
||||||
"""발행된 사이트 + 그 사업장을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다.
|
"""발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다.
|
||||||
|
(ErrorType, [(site, place, primary_photo_url)]).
|
||||||
|
|
||||||
★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로
|
★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로
|
||||||
돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 —
|
돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 —
|
||||||
여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다."""
|
여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다.
|
||||||
|
|
||||||
|
★ primary_photo_url 은 list_owner_sites 와 같은 서브쿼리(_primary_photo_subquery) —
|
||||||
|
sites.thumbnail_url 이 비어 있을 때 showcase_service 가 이걸로 대신 채운다."""
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(sites, places)
|
select(sites, places, _primary_photo_subquery())
|
||||||
.join(places, places.place_id == sites.place_id)
|
.join(places, places.place_id == sites.place_id)
|
||||||
.where(
|
.where(
|
||||||
sites.status == SiteStatus.PUBLISHED.value,
|
sites.status == SiteStatus.PUBLISHED.value,
|
||||||
|
|||||||
@ -97,6 +97,8 @@ class MySiteData(WebPacketProtocol):
|
|||||||
template_id: Optional[str] = None
|
template_id: Optional[str] = None
|
||||||
published_at: Optional[datetime] = None
|
published_at: Optional[datetime] = None
|
||||||
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
|
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
|
||||||
|
# Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발) 빌더가 쓰는 대표 사진으로 대신 채운다
|
||||||
|
# (site_service._my_site_row) — 이때는 `?v=` 가 없다.
|
||||||
thumbnail_url: Optional[str] = None
|
thumbnail_url: Optional[str] = None
|
||||||
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
|
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
|
||||||
needs_rebuild: bool = False
|
needs_rebuild: bool = False
|
||||||
|
|||||||
@ -37,8 +37,11 @@ class ShowcaseService:
|
|||||||
# ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면
|
# ★ 주소 규칙은 site_payload 한 곳뿐이다 — 여기서 다시 만들면
|
||||||
# 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳').
|
# 카드가 가리키는 곳과 실제 발행 주소가 갈린다(CLAUDE.md '슬러그 규칙은 두 곳').
|
||||||
url=f"/s/{site_payload.publish_slug(place, site)}",
|
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
|
return res
|
||||||
|
|||||||
@ -460,14 +460,24 @@ class SiteService:
|
|||||||
if err_type != ErrorType.SUCCESS:
|
if err_type != ErrorType.SUCCESS:
|
||||||
res.result.SetResult(err_type)
|
res.result.SetResult(err_type)
|
||||||
return res
|
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
|
res.total = total
|
||||||
return res
|
return res
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _my_site_row(place, site, built_at) -> MySiteData:
|
def _my_site_row(place, site, built_at, primary_photo_url) -> MySiteData:
|
||||||
# ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
|
# ★ 재빌드 판별은 단건(get_site)과 같은 규칙이어야 한다 — 다르면 목록과 에디터가 다른 답을 한다.
|
||||||
changed = place.content_updated_at
|
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(
|
return MySiteData(
|
||||||
place_id=place.place_id,
|
place_id=place.place_id,
|
||||||
name=place.name,
|
name=place.name,
|
||||||
@ -480,7 +490,7 @@ class SiteService:
|
|||||||
domain=getattr(site, "domain", None),
|
domain=getattr(site, "domain", None),
|
||||||
template_id=getattr(site, "template_id", None),
|
template_id=getattr(site, "template_id", None),
|
||||||
published_at=getattr(site, "published_at", 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)),
|
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) =>
|
const naverSearch = (q: string) =>
|
||||||
`https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`;
|
`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줄 · "검색으로 열기".
|
* 가이드 카드 한 장 — 사진(좌하단 "도보 약 N분 850m" 배지) · 이름 · 설명 2줄 · "검색으로 열기".
|
||||||
*
|
*
|
||||||
@ -15,11 +18,21 @@ const naverSearch = (q: string) =>
|
|||||||
* 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다.
|
* 남의 사진을 채우지 않는다 — 카드 폭이 들쭉날쭉해지면 캐러셀이 흔들린다.
|
||||||
* ★ 거리를 모르면 배지를 생략한다. "도보 N분"은 업장 기준 직선거리에서만 계산한다.
|
* ★ 거리를 모르면 배지를 생략한다. "도보 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;
|
const minutes = card.distanceMeters !== undefined ? walkMinutes(card.distanceMeters) : undefined;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={naverSearch(card.searchQuery)}
|
href={linkUrl(card.searchQuery)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@ -74,11 +87,14 @@ export function PlaceCarousel({
|
|||||||
cards,
|
cards,
|
||||||
colors,
|
colors,
|
||||||
renderLeading,
|
renderLeading,
|
||||||
|
linkUrl,
|
||||||
}: {
|
}: {
|
||||||
cards: GuideCard[];
|
cards: GuideCard[];
|
||||||
colors: TemplateItem['colors'];
|
colors: TemplateItem['colors'];
|
||||||
/** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */
|
/** 카드 이름 위에 얹을 배지(축제의 "10월" 등). */
|
||||||
renderLeading?: (card: GuideCard) => ReactNode;
|
renderLeading?: (card: GuideCard) => ReactNode;
|
||||||
|
/** 카드가 열 링크. 기본은 네이버 웹 검색이고, 맛집·명소는 네이버지도 검색(naverMapSearch)을 넘긴다. */
|
||||||
|
linkUrl?: (query: string) => string;
|
||||||
}) {
|
}) {
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
const [perPage, setPerPage] = useState(1);
|
const [perPage, setPerPage] = useState(1);
|
||||||
@ -171,6 +187,7 @@ export function PlaceCarousel({
|
|||||||
card={card}
|
card={card}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
leading={renderLeading?.(card)}
|
leading={renderLeading?.(card)}
|
||||||
|
linkUrl={linkUrl}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -19,5 +19,5 @@ export {Pill} from './Pill';
|
|||||||
export {CtaLink, type CtaStyle} from './CtaLink';
|
export {CtaLink, type CtaStyle} from './CtaLink';
|
||||||
export {Rail} from './Rail';
|
export {Rail} from './Rail';
|
||||||
/* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */
|
/* 주변 정보(업장 좌표 기준) — 시안 이후 들어온 것이라 시안 쪽 index 에는 없다. */
|
||||||
export {PlaceCarousel} from './PlaceCarousel';
|
export {PlaceCarousel, naverMapSearch} from './PlaceCarousel';
|
||||||
export {WalkFilterTabs} from './WalkFilterTabs';
|
export {WalkFilterTabs} from './WalkFilterTabs';
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export function LocalCategorySection({
|
|||||||
emptyText,
|
emptyText,
|
||||||
colors,
|
colors,
|
||||||
renderLeading,
|
renderLeading,
|
||||||
|
linkUrl,
|
||||||
}: {
|
}: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
title: string;
|
title: string;
|
||||||
@ -25,6 +26,7 @@ export function LocalCategorySection({
|
|||||||
emptyText: string;
|
emptyText: string;
|
||||||
colors: TemplateItem['colors'];
|
colors: TemplateItem['colors'];
|
||||||
renderLeading?: (card: GuideCard) => ReactNode;
|
renderLeading?: (card: GuideCard) => ReactNode;
|
||||||
|
linkUrl?: (query: string) => string;
|
||||||
}) {
|
}) {
|
||||||
const [filter, setFilter] = useState<WalkFilterKey>('all');
|
const [filter, setFilter] = useState<WalkFilterKey>('all');
|
||||||
const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters));
|
const visible = cards.filter((c) => matchesWalkFilter(filter, c.distanceMeters));
|
||||||
@ -52,7 +54,7 @@ export function LocalCategorySection({
|
|||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<EmptyStateNotice>이 거리 안에는 아직 없습니다. 다른 구간을 눌러 보세요.</EmptyStateNotice>
|
<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 {Calendar, MapPin, Utensils} from 'lucide-react';
|
||||||
import {useLocalGuide} from '@/hooks/useLocalGuide';
|
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 {SectionRenderProps} from '../../types';
|
||||||
import type {FestivalCard, GuideCard} from './types';
|
import type {FestivalCard, GuideCard} from './types';
|
||||||
import {LocalCategorySection} from './LocalCategorySection';
|
import {LocalCategorySection} from './LocalCategorySection';
|
||||||
@ -46,12 +46,16 @@ export function LocalGuide(props: SectionRenderProps) {
|
|||||||
colors={colors}
|
colors={colors}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ★ 맛집은 사진이 없으면 목록에서 뺀다 (2026-09-15, 사장님 지시) — 명소는 이름 활자로 대신하는
|
||||||
|
규칙을 유지하지만, 맛집은 실제 사이트(LocalGuideSection)와 맞춰 사진 없는 곳을 숨긴다. */}
|
||||||
|
{/* ★ 맛집·명소 카드는 네이버지도 검색으로 연다 (2026-09-15, 사장님 지시) — 실제 사이트와 동일. */}
|
||||||
<LocalCategorySection
|
<LocalCategorySection
|
||||||
icon={Utensils}
|
icon={Utensils}
|
||||||
title="주변 맛집"
|
title="주변 맛집"
|
||||||
cards={foods}
|
cards={foods.filter((card) => card.imageUrl)}
|
||||||
emptyText="주변 맛집·카페 추천은 아직 준비 중입니다."
|
emptyText="주변 맛집·카페 추천은 아직 준비 중입니다."
|
||||||
colors={colors}
|
colors={colors}
|
||||||
|
linkUrl={naverMapSearch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LocalCategorySection
|
<LocalCategorySection
|
||||||
@ -60,6 +64,7 @@ export function LocalGuide(props: SectionRenderProps) {
|
|||||||
cards={spots}
|
cards={spots}
|
||||||
emptyText="주변 명소 추천은 아직 준비 중입니다."
|
emptyText="주변 명소 추천은 아직 준비 중입니다."
|
||||||
colors={colors}
|
colors={colors}
|
||||||
|
linkUrl={naverMapSearch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isStay && (
|
{isStay && (
|
||||||
|
|||||||
@ -34,6 +34,16 @@ export function naverSearchUrl(query: string): string {
|
|||||||
return `https://search.naver.com/search.naver?query=${encodeURIComponent(query)}`;
|
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} />}
|
{otherRows.length > 0 && <Rows title="시설 · 편의" rows={otherRows} />}
|
||||||
{guides.filter((link) => link.stayGuide?.reservation).map((link) => (
|
{guides.filter((link) => link.stayGuide?.reservation).map((link) => (
|
||||||
<Rows key={`reservation-${link.url}`} title="예약 공지" emphasis rows={[
|
<Rows key={`reservation-${link.url}`} title="예약 공지" emphasis rows={[
|
||||||
{label: 'NOL 예약 공지', value: link.stayGuide?.reservation ?? ''},
|
{label: '예약 공지', value: link.stayGuide?.reservation ?? ''},
|
||||||
]} />
|
]} />
|
||||||
))}
|
))}
|
||||||
<BookingRow />
|
<BookingRow />
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import {ArrowUpRight, MapPin, Utensils} from 'lucide-react';
|
|||||||
import type {LocalPlace} from '@o2o/shared';
|
import type {LocalPlace} from '@o2o/shared';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useSite} from '@site/lib/site-context';
|
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';
|
import {Carousel, CarouselSlide, Section} from '@site/lib/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -72,11 +72,14 @@ export function LocalGuideSection() {
|
|||||||
const {local} = payload;
|
const {local} = payload;
|
||||||
// ★ 축제·행사는 여기서 그리지 않는다 (2026-09-03) — 날짜가 지나면 못 가는 것이라
|
// ★ 축제·행사는 여기서 그리지 않는다 (2026-09-03) — 날짜가 지나면 못 가는 것이라
|
||||||
// 아무 때나 가는 맛집·명소와 성격이 다르다. 계절을 축으로 세운 FestivalSection 이 맡는다.
|
// 아무 때나 가는 맛집·명소와 성격이 다르다. 계절을 축으로 세운 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');
|
const [range, setRange] = useState<string>('all');
|
||||||
if (!hasAnything) return null;
|
if (!hasAnything) return null;
|
||||||
|
|
||||||
const all = [...local.restaurants, ...local.attractions];
|
const all = [...restaurants, ...local.attractions];
|
||||||
const count = (r: (typeof RANGES)[number]) =>
|
const count = (r: (typeof RANGES)[number]) =>
|
||||||
all.filter((place) => r.test(distanceOf(place))).length;
|
all.filter((place) => r.test(distanceOf(place))).length;
|
||||||
// 비었거나 전체와 똑같은 탭은 세우지 않는다 — 눌러도 그대로인 탭은 고장으로 읽힌다.
|
// 비었거나 전체와 똑같은 탭은 세우지 않는다 — 눌러도 그대로인 탭은 고장으로 읽힌다.
|
||||||
@ -126,8 +129,8 @@ export function LocalGuideSection() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{local.restaurants.length > 0 && (
|
{restaurants.length > 0 && (
|
||||||
<PlaceList title="주변 맛집" icon={Utensils} places={local.restaurants} within={within} />
|
<PlaceList title="주변 맛집" icon={Utensils} places={restaurants} within={within} />
|
||||||
)}
|
)}
|
||||||
{local.attractions.length > 0 && (
|
{local.attractions.length > 0 && (
|
||||||
<PlaceList title="주변 명소" icon={MapPin} places={local.attractions} within={within} />
|
<PlaceList title="주변 명소" icon={MapPin} places={local.attractions} within={within} />
|
||||||
@ -181,7 +184,7 @@ function PlaceList({
|
|||||||
{shown.map((place) => (
|
{shown.map((place) => (
|
||||||
<CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
|
<CarouselSlide key={place.name} basis="basis-[76%] sm:basis-1/3 lg:basis-1/4">
|
||||||
<a
|
<a
|
||||||
href={naverSearchUrl(place.searchQuery)}
|
href={naverMapSearchUrl(place.searchQuery)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer nofollow"
|
rel="noopener noreferrer nofollow"
|
||||||
className="panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85"
|
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))
|
.filter((place) => !within(place))
|
||||||
.map((place) => (
|
.map((place) => (
|
||||||
<li key={place.name}>
|
<li key={place.name}>
|
||||||
<a href={naverSearchUrl(place.searchQuery)} rel="nofollow">
|
<a href={naverMapSearchUrl(place.searchQuery)} rel="nofollow">
|
||||||
{place.name}
|
{place.name}
|
||||||
</a>
|
</a>
|
||||||
{place.distanceText && (
|
{place.distanceText && (
|
||||||
|
|||||||
@ -9,9 +9,9 @@ it('renders collected descriptions and addresses with numeric distance filters',
|
|||||||
payload.local.attractions = [];
|
payload.local.attractions = [];
|
||||||
payload.local.restaurants = [
|
payload.local.restaurants = [
|
||||||
{name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', location: 'Address A',
|
{name: 'Cafe A', category: 'cafe', searchQuery: 'Cafe A', location: 'Address A',
|
||||||
description: 'Collected description', distanceMeters: 401, distanceText: '400m'},
|
description: 'Collected description', distanceMeters: 401, distanceText: '400m', imageUrl: 'https://example.com/a.jpg'},
|
||||||
{name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100},
|
{name: 'Cafe B', category: 'cafe', searchQuery: 'Cafe B', distanceMeters: 100, imageUrl: 'https://example.com/b.jpg'},
|
||||||
{name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C'},
|
{name: 'Cafe C', category: 'cafe', searchQuery: 'Cafe C', imageUrl: 'https://example.com/c.jpg'},
|
||||||
];
|
];
|
||||||
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
|
const html = renderToStaticMarkup(<SiteProvider payload={payload}><LocalGuideSection /></SiteProvider>);
|
||||||
expect(html).toContain('Collected description');
|
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).toMatch(/걸어서 5분 이내 <span[^>]*>1<\/span>/);
|
||||||
expect(html).toContain('Cafe C');
|
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