diff --git a/.env.example b/.env.example
index dab8ed8..950d8b1 100644
--- a/.env.example
+++ b/.env.example
@@ -44,7 +44,8 @@ GOOGLE_CLIENT_ID=
# ── solution/site 발행물 — solution/backend 도 같이 본다
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
-SITE_PUBLIC_HOST=web4ai.o2osolution.ai
+# 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
+# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상)
AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다
AZURE_STORAGE_CONTAINER=
diff --git a/docker-compose.yml b/docker-compose.yml
index f9a02a1..59f677a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -25,7 +25,9 @@ x-common-env: &common-env
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
- SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
+ # ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
+ # 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
+ SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
SITE_PAYLOAD_DIR: /app/out/payloads
SITE_OUTPUT_DIR: /app/out/sites
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
@@ -148,7 +150,7 @@ services:
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
+ VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@@ -186,11 +188,11 @@ services:
environment:
PORT: 3001
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
- VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
+ VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
- VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
+ VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
@@ -231,7 +233,7 @@ services:
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
# 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts).
# 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다.
- VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
+ VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@@ -259,8 +261,8 @@ services:
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
# → 주소를 바꿨으면 `./deploy.sh solution-site`.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
- VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
- VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
+ VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
+ VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
diff --git a/solution/backend/common/utils/ttl_cache.py b/solution/backend/common/utils/ttl_cache.py
new file mode 100644
index 0000000..26fc4b9
--- /dev/null
+++ b/solution/backend/common/utils/ttl_cache.py
@@ -0,0 +1,36 @@
+"""TTL 캐시 — 프로세스 메모리에만 있다. rate_limit 과 같은 한계를 갖는다.
+
+★ 두는 이유는 속도가 아니라 **차단**이다. 공개 검색 1회가 네이버를 최대 3번 긁는데
+ (넓은 검색 1 + 겨냥 2), 인증 없는 경로라 새로고침만으로도 나간다.
+ 실측(2026-09-03): 테스트를 반복하다 m.place.naver.com 에서 429 를 받았다.
+"""
+import time
+from typing import Any, Optional
+
+_store: dict[str, tuple[float, Any]] = {}
+_MAX_KEYS = 500
+
+
+def get(key: str) -> Optional[Any]:
+ hit = _store.get(key)
+ if not hit:
+ return None
+ if time.monotonic() >= hit[0]:
+ _store.pop(key, None)
+ return None
+ return hit[1]
+
+
+def put(key: str, value: Any, ttl_sec: float) -> None:
+ # 만료된 것부터 버리고, 그래도 넘치면 가장 먼저 만료될 것을 버린다.
+ if len(_store) >= _MAX_KEYS:
+ now = time.monotonic()
+ for k in [k for k, (exp, _) in _store.items() if exp <= now]:
+ _store.pop(k, None)
+ if len(_store) >= _MAX_KEYS:
+ _store.pop(min(_store, key=lambda k: _store[k][0]), None)
+ _store[key] = (time.monotonic() + ttl_sec, value)
+
+
+def clear() -> None:
+ _store.clear()
diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py
index e9edf4e..2fb75ea 100644
--- a/solution/backend/router/v1/place/protocol.py
+++ b/solution/backend/router/v1/place/protocol.py
@@ -223,6 +223,9 @@ class PlaceSearchItem(WebPacketProtocol):
# 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
category: Optional[PlaceCategory] = None
+ # 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다.
+ # 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다.
+ naver_place_url: Optional[str] = None
class Res_PlaceSearch(Res_WebPacketProtocol):
diff --git a/solution/backend/services/external/naver_place_lookup.py b/solution/backend/services/external/naver_place_lookup.py
index b8a9db4..1efe49a 100644
--- a/solution/backend/services/external/naver_place_lookup.py
+++ b/solution/backend/services/external/naver_place_lookup.py
@@ -52,6 +52,20 @@ def _normalize(text: str) -> str:
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower()
+def _region_hint(address: Optional[str]) -> str:
+ """주소에서 검색을 좁힐 지역 토막. 시/군/구까지만 쓴다.
+
+ ★ 첫 토막만 쓰면 안 된다 — 그건 광역시·도('경기도')라 오히려 넓어진다.
+ 실측(2026-09-03 '버터브루'): '버터브루 경기도' 로 찾으면 **다른 동네 동명 업소**의
+ id 가 잡히고, 그 id 로 검증하면 남의 가게가 이 사이트의 기준 정보가 된다.
+ '버터브루 성남시 중원구' 로 좁히면 정확히 잡힌다.
+ ★ 도로명·번지는 넣지 않는다. 검색 결과가 그 주소를 언급한 블로그로 채워져 id 가 사라진다.
+ """
+ tokens = (address or "").split()
+ picked = [t for t in tokens if t.endswith(("시", "군", "구"))]
+ return " ".join(picked[:2])
+
+
async def _fetch_search_html(query: str) -> Optional[str]:
"""통합검색 결과 페이지 원문. 실패는 None — 호출측이 조용히 폴백한다."""
try:
@@ -114,7 +128,7 @@ async def find_place_id(name: str, address: Optional[str] = None) -> Optional[st
★ 이름이 일치하는 후보만 받는다. '비슷한 것 중 첫 번째'를 고르면 남의 가게를
이 가게의 공식 채널로 등록하게 된다 — 이 제품에서 가장 비싼 실수다.
"""
- query = " ".join(x for x in (name, (address or "").split()[0] if address else "") if x)
+ query = " ".join(x for x in (name, _region_hint(address)) if x)
html = await _fetch_search_html(query)
if not html:
return None
diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py
index 385fde5..ef78d95 100644
--- a/solution/backend/services/place_service.py
+++ b/solution/backend/services/place_service.py
@@ -50,6 +50,12 @@ from services.external.naver import region_key
from services.job_service import enqueue_job
+# 겨냥 조회를 몇 건까지 할지. 후보 전부(5건)를 부르면 통합검색이 429 를 준다.
+_TARGETED_LOOKUP_LIMIT = 2
+# 공개 검색 캐시 수명. 가게 정보가 이 안에 바뀔 일은 없다.
+_PUBLIC_SEARCH_CACHE_SEC = 600
+
+
class PlaceService:
"""사업장 등록·조회·동일 업소 검증.
@@ -605,9 +611,10 @@ class PlaceService:
★ DB 를 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 가 공개적으로 주는 것뿐이다.
"""
from common.enums import ExternalPlaceSource
- from common.utils import rate_limit
+ from common.utils import rate_limit, ttl_cache
from services.external import kakao as kakao_client
from services.external import naver as naver_client
+ from services.external import naver_place_lookup
from services.place_category import guess_category
res = Res_PlaceSearch()
@@ -616,6 +623,14 @@ class PlaceService:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
+ # 같은 검색어는 캐시로 받는다. 사장님이 상호를 고쳐 가며 대여섯 번 치는 동안
+ # 네이버를 매번 세 번씩 긁지 않게 한다.
+ cache_key = f"place-search:{q}"
+ cached = ttl_cache.get(cache_key)
+ if cached is not None:
+ res.source, res.items = cached
+ return res
+
# ★ 인증이 없는데 유료 외부 API 를 부른다 — 방어가 0 이면 새로고침만으로 요금이 나간다.
# 프로세스 메모리 기반이라 완전하지 않다(common/utils/rate_limit.py 주석).
if not rate_limit.allow(f"place-search:{client_ip}", _PUBLIC_SEARCH_PER_MIN, 60.0):
@@ -644,6 +659,23 @@ class PlaceService:
res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED)
return res
+ # ★ 여기서 네이버 플레이스 주소까지 찾아 실어 보낸다.
+ # 전에는 확정 경로(verify/candidates)에서만 찾았는데, 그건 로그인 뒤라 사장님이
+ # 후보를 고를 때는 "이 가게의 플레이스가 있는지" 를 알 수 없었다. 화면은 그걸
+ # "자동으로 못 찾았다" 로 읽고 지도 주소를 물었다 — 찾을 수 있는데도.
+ # 실패는 조용히 넘긴다(None). 유료 API 가 아니라 공개 페이지 조회다.
+ naver_ids: dict[str, str] = {}
+ try:
+ naver_ids = await naver_place_lookup.find_place_ids(q, [r.name for r in rows])
+ for row in rows[:_TARGETED_LOOKUP_LIMIT]:
+ if row.name in naver_ids:
+ continue
+ found = await naver_place_lookup.find_place_id(row.name, row.road_address or row.address)
+ if found:
+ naver_ids[row.name] = found
+ except Exception as ex: # noqa: BLE001 — 지도 주소 붙여넣기로 이어진다
+ LOG.w(f"[search] 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}")
+
res.items = [
PlaceSearchItem(
name=row.name,
@@ -651,11 +683,19 @@ class PlaceService:
category_name=row.category_name,
# 네이버는 그룹코드를 주지 않는다 — 그때는 분류 문자열만으로 추정한다.
category=guess_category(row.category_name, getattr(row, "category_group_code", None)),
+ naver_place_url=(
+ naver_place_lookup.place_url(naver_ids[row.name]) if row.name in naver_ids else None
+ ),
)
for row in rows
]
if not res.items:
+ # ★ 빈 결과는 캐시하지 않는다. 네이버가 잠깐 막아서 0건이 나온 것을 굳히면
+ # 사장님은 TTL 이 끝날 때까지 아무것도 못 한다.
res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE)
+ return res
+
+ ttl_cache.put(cache_key, (res.source, res.items), _PUBLIC_SEARCH_CACHE_SEC)
return res
async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates:
@@ -731,6 +771,22 @@ class PlaceService:
except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다
LOG.w(f"[verify] 네이버 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}")
naver_ids = {}
+ # ★ 넓은 검색어 한 페이지에서 못 찾은 후보는 **그 후보만 겨냥해** 한 번 더 찾는다.
+ # 넓은 검색은 결과가 다른 지점으로 채워져 이름이 아예 안 실릴 때가 있다 —
+ # 실측(2026-09-03 '스타벅스 판교역점'): 페이지에 id 6개가 있는데 지역검색이 준
+ # 5개 지점명은 하나도 그 근처에 없었다. 상호+지역으로 겨냥하면 4/4 로 찾는다.
+ # 호출은 상위 후보 몇 건으로 끊는다 — 5건을 다 부르면 429 를 받는다.
+ for c in rows[:_TARGETED_LOOKUP_LIMIT]:
+ if c.name in naver_ids:
+ continue
+ try:
+ found = await naver_place_lookup.find_place_id(c.name, c.road_address or c.address)
+ except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다
+ LOG.w(f"[verify] 겨냥 조회 실패({c.name}): {type(ex).__name__}: {ex}")
+ break
+ if found:
+ naver_ids[c.name] = found
+
res.candidates = [
PlaceCandidate(
external_place_id=getattr(c, "kakao_place_id", None) or getattr(c, "naver_place_id", None),
diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py
index ee3d827..1281900 100644
--- a/solution/backend/services/site_payload.py
+++ b/solution/backend/services/site_payload.py
@@ -15,6 +15,7 @@
(channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.)
"""
import json
+import re
import os
import unicodedata
from datetime import datetime, timezone
@@ -45,7 +46,13 @@ DEFAULT_PAYLOAD_DIR = "/app/out/payloads"
# 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다).
# 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**.
SITE_HOST_ENV = "SITE_PUBLIC_HOST"
-DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "web4ai.o2osolution.ai"
+# 기본값은 localhost. 운영 도메인을 기본으로 두면 설정을 빠뜨린 환경이 조용히 운영 주소로
+# canonical·sitemap 을 굽는다 — 틀렸다는 걸 아무도 모른다.
+DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
+
+def _scheme(host: str) -> str:
+ """localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙."""
+ return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
# 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다.
_CHANNEL_TITLE = {
@@ -550,7 +557,7 @@ def _publish_target(site, place_id: str, name: str) -> dict:
name_slug = slugify(name)
slug = f"{name_slug}-{place_id[:8]}" if name_slug else f"place-{place_id[:8]}"
- return {"origin": f"https://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
+ return {"origin": f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
def publish_slug(place, site) -> str:
@@ -565,7 +572,7 @@ def publish_slug(place, site) -> str:
def publish_origin() -> str:
"""발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 —
호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳')."""
- return f"https://{DEFAULT_HOST}"
+ return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
def primary_media(snapshot: dict) -> dict | None:
diff --git a/solution/frontend/.env.example b/solution/frontend/.env.example
index d4c1074..61952c3 100644
--- a/solution/frontend/.env.example
+++ b/solution/frontend/.env.example
@@ -5,7 +5,7 @@ VITE_API_BASE_URL=http://localhost:9800
# 커스텀 도메인이 붙기 전까지 .<이 값> 형태로 나간다.
# ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가
# 주입돼 이 값을 덮는다 — 발행 호스트의 단일 출처는 루트 .env 다.
-VITE_PUBLISH_HOST=web4ai.o2osolution.ai
+VITE_PUBLISH_HOST=localhost
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
VITE_SITE_PREVIEW_URL=http://localhost:3000
diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts
index 1aacf27..92eac8d 100644
--- a/solution/frontend/src/api/generated/model/placeSearchItem.ts
+++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts
@@ -21,4 +21,6 @@ export interface PlaceSearchItem {
road_address?: PlaceSearchItemRoadAddress;
category_name?: PlaceSearchItemCategoryName;
category?: PlaceSearchItemCategory;
+ /** @nullable */
+ naver_place_url?: string | null;
}
diff --git a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx
index 651aa90..53a38e1 100644
--- a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx
+++ b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx
@@ -1,5 +1,5 @@
import {useState} from 'react';
-import {Link2, Loader2, MapPin, Plus} from 'lucide-react';
+import {ExternalLink, Link2, Loader2, MapPin, Search} from 'lucide-react';
import type {LinkChannel as LinkChannelCode} from '@/api';
import {LinkChannel} from '@o2o/shared';
import {Button} from '@/components/ui/button';
@@ -45,15 +45,18 @@ const HOW_TO = [
'네이버 지도(또는 네이버 검색)에서 내 가게를 찾습니다',
'가게 이름 옆 [공유] 를 누릅니다',
'[URL 복사] 를 눌러 주소를 복사합니다',
- '아래 칸에 붙여넣고 [추가] 를 누릅니다',
+ '아래 칸에 붙여넣고 [이 주소로 가져오기] 를 누릅니다',
];
export function ChannelUrlInput({
onAdd,
isBusy,
+ searchQuery,
}: {
onAdd: (url: string, channel: LinkChannelCode, title: string) => Promise;
isBusy?: boolean;
+ /** 이 상호로 네이버 지도를 바로 연다 — 사장님이 새 탭에서 직접 검색어를 치지 않게. */
+ searchQuery?: string;
}) {
const [url, setUrl] = useState('');
const [error, setError] = useState(null);
@@ -88,6 +91,20 @@ export function ChannelUrlInput({
까지 그대로 가져옵니다.
+ {/* ★ 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다.
+ "찾아 보세요" 라고만 하고 갈 곳을 안 주면 그 자리에서 멈춘다. */}
+ {searchQuery?.trim() && (
+
+
+ 네이버 지도에서 '{searchQuery.trim()}' 열기
+
+ )}
+
{HOW_TO.map((step, index) => (
@@ -112,9 +129,11 @@ export function ChannelUrlInput({
placeholder="https://map.naver.com/p/entry/place/1273971279"
disabled={isBusy}
/>
+ {/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.**
+ '추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */}
diff --git a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
index 16b1947..e952c8f 100644
--- a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
+++ b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
@@ -59,6 +59,15 @@ export function Step2PlaceSearch() {
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [, goToStep] = useWizardStep();
const [placeUrl, setPlaceUrl] = useState('');
+ /** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 —
+ * 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가
+ * 없을 때만 여는 탈출구다. */
+ const [urlBoxOpen, setUrlBoxOpen] = useState(false);
+ /** 고른 후보가 들고 온 네이버 플레이스 주소(검색 단계에서 서버가 찾아 준 값). */
+ const naverUrlRef = useRef(null);
+ /** 확정 직후 2단계로 넘어가는 한 번은 초기화를 건너뛴다. */
+ const justConfirmed = useRef(false);
+ const foundCount = search.items.filter((i) => i.naver_place_url).length;
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
@@ -81,7 +90,10 @@ export function Step2PlaceSearch() {
const identity = await search.confirmPick(pick, nextIndustry);
setPendingPick(null);
if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다
- confirmIdentity(identity);
+ justConfirmed.current = true;
+ confirmIdentity(
+ naverUrlRef.current ? {...identity, naverPlaceUrl: naverUrlRef.current} : identity,
+ );
advance(identity.placeId);
};
@@ -112,6 +124,9 @@ export function Step2PlaceSearch() {
name: item.name ?? '',
address: item.road_address ?? '',
};
+ // ★ 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다.
+ // 이게 있으면 수집 직전에 다시 찾을 필요가 없고, 사장님에게 지도 주소를 묻지도 않는다.
+ naverUrlRef.current = item.naver_place_url ?? null;
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
if (!guessed) {
setPendingPick(pick);
@@ -138,8 +153,9 @@ export function Step2PlaceSearch() {
const pickByUrl = async () => {
const url = placeUrl.trim();
if (!url) return;
- const identity = await search.confirmByUrl(url, industry);
+ const identity = await search.confirmByUrl(url);
if (!identity) return;
+ justConfirmed.current = true;
confirmIdentity(identity);
advance(identity.placeId);
};
@@ -163,6 +179,7 @@ export function Step2PlaceSearch() {
};
// ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ──────────────
+ //
const stage = confirmedIdentity
? 'confirmed'
: search.phase === 'searching'
@@ -173,7 +190,32 @@ export function Step2PlaceSearch() {
? 'picking'
: 'input';
+ /**
+ * 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.**
+ *
+ * ★ 왜 확정 화면을 안 띄우나: 뒤로가기는 "다시 고르겠다" 는 뜻인데, 확정 화면만 뜨면
+ * 다른 가게로 바꾸러 온 사장님이 목록을 못 찾는다. 후보 목록을 되살리는 방법도 있지만
+ * 공개 검색이 유료 외부 API 라 뒤로 올 때마다 다시 부르게 된다.
+ * ★ 이 화면을 **정상적으로 지나간** 경우(확정 직후 2단계로 넘어감)에는 돌지 않는다 —
+ * `justConfirmed` 가 그 한 번을 막는다. 아니면 확정하자마자 스스로 지워 버린다.
+ */
+ useEffect(() => {
+ if (!confirmedIdentity) return;
+ if (justConfirmed.current) {
+ justConfirmed.current = false;
+ return;
+ }
+ clearIdentity();
+ search.reset();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다
+ }, []);
+
const searchQuery = [storeName, location].filter(Boolean).join(' ');
+ /** 뒤로 와서 목록을 다시 볼 때, 이미 고른 후보를 알아보게 한다. */
+ const isPicked = (item: PlaceSearchItem) =>
+ Boolean(confirmedIdentity) &&
+ item.name === confirmedIdentity?.name &&
+ (item.road_address ?? '') === (confirmedIdentity?.address ?? '');
return (
@@ -249,9 +291,11 @@ export function Step2PlaceSearch() {
: 'border border-warning/30 bg-warning/10 text-warning',
)}
>
- {search.items.length > 0
- ? '아래 목록에서 사장님 가게를 골라 주세요.'
- : '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
+ {search.items.length === 0
+ ? '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'
+ : foundCount > 0
+ ? `${foundCount}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.`
+ : '아래 목록에서 사장님 가게를 골라 주세요.'}
{search.pickError && (
@@ -281,14 +325,33 @@ export function Step2PlaceSearch() {
)}
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
+ {/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면
+ 사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */}
+ {item.naver_place_url && (
+
+
+ 네이버 플레이스 찾음
+
+ )}